diff --git a/bundles/actual-budget/manifest.json b/bundles/actual-budget/manifest.json index ca10de739..c51279047 100644 --- a/bundles/actual-budget/manifest.json +++ b/bundles/actual-budget/manifest.json @@ -1,7 +1,7 @@ { "id": "actual-budget", "name": "Actual Budget", - "version": "1.0.0", + "version": "1.0.1", "description": "Privacy-first personal finance — track spending, manage budgets, and view financial reports", "type": "bundle", "author": "Crow", @@ -9,17 +9,19 @@ "tags": ["budget", "finance", "money", "personal-finance", "self-hosted"], "icon": "dollar", "docker": { "composefile": "docker-compose.yml" }, - "server": { "command": "node", "args": ["server/index.js"], "envKeys": ["ACTUAL_URL", "ACTUAL_PASSWORD"] }, + "server": { "command": "node", "args": ["server/index.js"], "envKeys": ["ACTUAL_URL", "ACTUAL_PASSWORD", "ACTUAL_SERVER_PASSWORD"] }, "panel": "panel/actual-budget.js", "panelRoutes": "panel/routes.js", "skills": ["skills/actual-budget.md"], - "requires": { "env": ["ACTUAL_PASSWORD"], "min_ram_mb": 256, "min_disk_mb": 200 }, + "requires": { "min_ram_mb": 256, "min_disk_mb": 200 }, + "postInstall": { "script": "scripts/set-server-password.sh", "timeout_s": 180 }, "env_vars": [ { "name": "ACTUAL_URL", "description": "Actual Budget server URL", "default": "http://localhost:5006", "required": true }, - { "name": "ACTUAL_PASSWORD", "description": "Actual server password", "required": true, "secret": true }, + { "name": "ACTUAL_PASSWORD", "description": "Password of an Actual server you already run elsewhere. Leave it empty for the Actual server Crow installs on this machine: Crow generates that one's password.", "required": false, "secret": true }, + { "name": "ACTUAL_SERVER_PASSWORD", "description": "Password of the Actual server Crow installs on this machine. Crow generates it, saves it in Settings → Passwords (this machine only) and sets it on the new server right after install. Log in to Actual with it.", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "server password", "keychain_url": "http://localhost:5006/" }, { "name": "ACTUAL_SYNC_ID", "description": "Budget sync ID to auto-select budget", "required": false } ], "ports": [5006], "webUI": { "port": 5006, "path": "/", "label": "Actual Budget" }, - "notes": "Privacy-first budgeting. Data stays local. Set ACTUAL_PASSWORD to match your Actual server password." + "notes": "Privacy-first budgeting. Data stays local. Crow sets a generated password on the Actual server it installs (Settings → Passwords). To use an Actual server you already run, set ACTUAL_URL and ACTUAL_PASSWORD instead." } diff --git a/bundles/actual-budget/panel/routes.js b/bundles/actual-budget/panel/routes.js index 304783aa2..141060c22 100644 --- a/bundles/actual-budget/panel/routes.js +++ b/bundles/actual-budget/panel/routes.js @@ -7,9 +7,33 @@ */ import { Router } from "express"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; const ACTUAL_URL = () => (process.env.ACTUAL_URL || "http://localhost:5006").replace(/\/+$/, ""); -const ACTUAL_PASSWORD = () => process.env.ACTUAL_PASSWORD || ""; + +function isLoopbackUrl(url) { + try { + const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host); + } catch { + return false; + } +} + +/** The generated password of the Actual server Crow installed here, from THIS instance's + * installed copy (co-hosted instances each have their own CROW_HOME). */ +function generatedServerPassword() { + try { + const envPath = join(process.env.CROW_HOME || join(homedir(), ".crow"), "bundles", "actual-budget", ".env"); + const line = readFileSync(envPath, "utf8").split("\n").reverse().find((l) => l.startsWith("ACTUAL_SERVER_PASSWORD=")); + return line ? line.slice("ACTUAL_SERVER_PASSWORD=".length).trim().replace(/^"(.*)"$/, "$1") : ""; + } catch { + return ""; + } +} + const ACTUAL_SYNC_ID = () => process.env.ACTUAL_SYNC_ID || ""; let authToken = null; @@ -25,18 +49,22 @@ async function getToken() { const timeout = setTimeout(() => controller.abort(), 10000); try { - const res = await fetch(`${ACTUAL_URL()}/account/login`, { - method: "POST", - signal: controller.signal, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password: ACTUAL_PASSWORD() }), - }); - - if (!res.ok) throw new Error("Actual Budget login failed — check ACTUAL_PASSWORD"); - const data = await res.json(); - authToken = data.data?.token || data.token; - if (!authToken) throw new Error("No token returned from login"); - return authToken; + // Typed password first, then (loopback only) the generated one: a stale typed value + // must not lock the panel out of the server Crow set up. + const candidates = [...new Set([process.env.ACTUAL_PASSWORD, isLoopbackUrl(ACTUAL_URL()) ? generatedServerPassword() : ""].filter(Boolean))]; + for (const password of candidates) { + const res = await fetch(`${ACTUAL_URL()}/account/login`, { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (!res.ok && res.status !== 400 && res.status !== 401) throw new Error(`Actual Budget login error: ${res.status}`); + const data = res.ok ? await res.json().catch(() => ({})) : {}; + authToken = data.data?.token || data.token || null; + if (authToken) return authToken; + } + throw new Error("Actual Budget login failed. The generated server password is in Settings → Passwords; if this server has another one, type it into ACTUAL_PASSWORD in Configure."); } catch (err) { if (err.name === "AbortError") throw new Error("Actual Budget login timed out"); if (err.message.includes("fetch failed") || err.message.includes("ECONNREFUSED")) { diff --git a/bundles/actual-budget/scripts/set-server-password.sh b/bundles/actual-budget/scripts/set-server-password.sh new file mode 100755 index 000000000..a43540fd7 --- /dev/null +++ b/bundles/actual-budget/scripts/set-server-password.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# postInstall: give the Actual server Crow just installed its generated password, right +# away, so nobody who opens the web UI first can set their own (Actual's first-run +# "bootstrap" is first caller wins). Loopback only: the container is published on +# 127.0.0.1:5006. Never prints the password. Idempotent: a server that already has a +# password is left alone (Actual itself refuses a second bootstrap). +set -euo pipefail +ENV_FILE="${CROW_BUNDLE_DIR:-.}/.env" +URL="${ACTUAL_BOOTSTRAP_URL:-http://127.0.0.1:5006}" # override: tests only (hookEnv never passes it) +[[ "$URL" =~ ^http://127\.0\.0\.1:[0-9]+$ ]] || { echo "Refusing a non-loopback Actual URL."; exit 0; } + +pw="$( (sed -n 's/^ACTUAL_SERVER_PASSWORD=//p' "$ENV_FILE" 2>/dev/null || true) | tail -n 1)" +pw="${pw%\"}"; pw="${pw#\"}" +if [ -z "$pw" ]; then + echo "No generated server password in the extension settings; nothing to set." + exit 0 +fi +if ! [[ "$pw" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "The server password is not in the generated format; set it in Actual's web UI instead." + exit 0 +fi +command -v curl >/dev/null || { echo "curl is not installed; Crow sets the password the first time it connects instead."; exit 0; } + +state="" +for _ in $(seq 1 "${ACTUAL_BOOTSTRAP_TRIES:-20}"); do + state="$(curl -fsS --max-time 5 "$URL/account/needs-bootstrap" 2>/dev/null || true)" + [ -n "$state" ] && break + sleep 2 +done +if [ -z "$state" ]; then + echo "The Actual server did not answer within 2 minutes; Crow sets the password the first time it connects instead." + exit 0 +fi +already_set() { + # Kept data from an earlier install has its own password: say so when ours doesn't open it. + login="$(printf '{"password":"%s"}' "$pw" | curl -sS --max-time 10 -H 'Content-Type: application/json' --data-binary @- "$URL/account/login" || true)" + if printf '%s' "$login" | grep -q '"token"'; then + echo "The Actual server already has this password; left unchanged." + else + echo "The Actual server kept its password from an earlier install, so the one Crow generated does not open it. Type the old password into ACTUAL_PASSWORD in Configure (and ignore the generated one in Settings → Passwords)." + fi +} +if ! printf '%s' "$state" | grep -q '"bootstrapped":false'; then + already_set + exit 0 +fi +# The body goes in on stdin, so the password never appears in a process list. +resp="$(printf '{"password":"%s"}' "$pw" | curl -sS --max-time 10 -H 'Content-Type: application/json' --data-binary @- "$URL/account/bootstrap" || true)" +if printf '%s' "$resp" | grep -q '"status":"ok"'; then + echo "Set the generated password on the Actual server (Settings → Passwords)." +elif printf '%s' "$resp" | grep -q 'already-bootstrapped'; then + already_set +else + echo "Could not set the Actual server password; Crow tries again the first time it connects." +fi diff --git a/bundles/actual-budget/server/bootstrap.js b/bundles/actual-budget/server/bootstrap.js new file mode 100644 index 000000000..337080937 --- /dev/null +++ b/bundles/actual-budget/server/bootstrap.js @@ -0,0 +1,83 @@ +/** + * First-run password for the Actual server Crow deployed (queue item 30, fix B). + * + * Crow generates ACTUAL_SERVER_PASSWORD at install and saves it in the Crow keychain; the + * postInstall hook (scripts/set-server-password.sh) sets it right away, and this is the + * fallback when that could not run. A new + * Actual server has no password yet ("needs bootstrap"), so before the first login we + * set it: GET /account/needs-bootstrap, and only when it reports bootstrapped:false, + * POST /account/bootstrap { password }. The server itself refuses a second bootstrap + * ("already-bootstrapped"), so a server someone already set up is never changed. + * + * Only for a LOOPBACK server (the bundle's own container is published on 127.0.0.1). + * A remote Actual server the user points Crow at is theirs to set up; Crow never sends + * it a password it chose. + */ + +export function isLoopbackUrl(url) { + try { + const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host); + } catch { + return false; + } +} + +/** + * The password to log in with: ACTUAL_PASSWORD when set (typed: a server the user runs + * elsewhere), else ACTUAL_SERVER_PASSWORD (generated for the local server) — but that one + * only for a loopback URL, so it never leaves this machine. + */ +export function effectivePassword(url, env = process.env) { + if (env.ACTUAL_PASSWORD) return env.ACTUAL_PASSWORD; + return isLoopbackUrl(url) ? (env.ACTUAL_SERVER_PASSWORD || "") : ""; +} + +/** + * Passwords to try, in order: the typed one, then (loopback only) the generated one. A + * stale typed value (kept in the gateway .env from an older install) must not lock the + * user out of the server Crow just set up with the generated password. + */ +export function loginCandidates(url, env = process.env) { + const out = [env.ACTUAL_PASSWORD, isLoopbackUrl(url) ? env.ACTUAL_SERVER_PASSWORD : ""].filter((v) => typeof v === "string" && v !== ""); + return [...new Set(out)]; +} + +/** + * @returns {Promise<"bootstrapped"|"ready"|"skipped">} + * bootstrapped this call set the password + * ready the server already had one + * skipped no password, a non-loopback server, or the check failed (login decides) + */ +export async function ensureBootstrapped(baseUrl, password, { fetchImpl = globalThis.fetch, timeoutMs = 10000 } = {}) { + if (!password || !isLoopbackUrl(baseUrl)) return "skipped"; + const base = String(baseUrl).replace(/\/+$/, ""); + const call = async (path, init = {}) => { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), timeoutMs); + try { + return await fetchImpl(`${base}${path}`, { ...init, signal: ctl.signal }); + } finally { + clearTimeout(timer); + } + }; + let state; + try { + const res = await call("/account/needs-bootstrap"); + if (!res.ok) return "skipped"; + state = await res.json(); + } catch { + return "skipped"; + } + if (state?.data?.bootstrapped !== false) return "ready"; + const res = await call("/account/bootstrap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + let body = null; + try { body = await res.json(); } catch { /* reason unknown */ } + if (res.ok && body?.status === "ok") return "bootstrapped"; + if (body?.reason === "already-bootstrapped") return "ready"; + throw new Error(`Could not set the Actual server password (${body?.reason || res.status})`); +} diff --git a/bundles/actual-budget/server/server.js b/bundles/actual-budget/server/server.js index 83b118de7..5dfe40960 100644 --- a/bundles/actual-budget/server/server.js +++ b/bundles/actual-budget/server/server.js @@ -10,15 +10,16 @@ * - Update budget amounts * - Generate spending reports * - * Auth: POST /account/login with { password } to get a Bearer token. + * Auth: POST /account/login with { password } to get a Bearer token. A new local + * server is given the generated password first (bootstrap.js). * Amounts are stored in cents (integer). Negative = expense, positive = income. */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { ensureBootstrapped, loginCandidates } from "./bootstrap.js"; const ACTUAL_URL = (process.env.ACTUAL_URL || "http://localhost:5006").replace(/\/+$/, ""); -const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD || ""; const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID || ""; let authToken = null; @@ -34,24 +35,22 @@ async function getToken() { const timeout = setTimeout(() => controller.abort(), 10000); try { - const res = await fetch(`${ACTUAL_URL}/account/login`, { - method: "POST", - signal: controller.signal, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password: ACTUAL_PASSWORD }), - }); - - if (!res.ok) { - if (res.status === 401 || res.status === 400) { - throw new Error("Actual Budget login failed — check ACTUAL_PASSWORD"); - } - throw new Error(`Actual Budget login error: ${res.status} ${res.statusText}`); + // A new server Crow deployed has no password yet: set the GENERATED one (never a + // typed value, which may be stale from an older install), loopback only. + await ensureBootstrapped(ACTUAL_URL, process.env.ACTUAL_SERVER_PASSWORD || ""); + for (const password of loginCandidates(ACTUAL_URL, process.env)) { + const res = await fetch(`${ACTUAL_URL}/account/login`, { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (!res.ok && res.status !== 400 && res.status !== 401) throw new Error(`Actual Budget login error: ${res.status} ${res.statusText}`); + const data = res.ok ? await res.json().catch(() => ({})) : {}; + authToken = data.data?.token || data.token || null; + if (authToken) return authToken; } - - const data = await res.json(); - authToken = data.data?.token || data.token; - if (!authToken) throw new Error("No token returned from Actual Budget login"); - return authToken; + throw new Error("Actual Budget login failed. The generated server password is in Settings → Passwords; if this server has another password (changed in Actual, or kept data from an earlier install), type it into ACTUAL_PASSWORD in Configure."); } catch (err) { if (err.name === "AbortError") { throw new Error("Actual Budget login timed out after 10s"); diff --git a/bundles/actual-budget/skills/actual-budget.md b/bundles/actual-budget/skills/actual-budget.md index 4db15aa75..b21a08b47 100644 --- a/bundles/actual-budget/skills/actual-budget.md +++ b/bundles/actual-budget/skills/actual-budget.md @@ -98,6 +98,6 @@ tools: ## Error Handling - If Actual Budget is unreachable: "Can't connect to Actual Budget at the configured URL. Make sure the server is running." -- If auth fails: "Actual Budget rejected the password. Check ACTUAL_PASSWORD in settings." +- If auth fails: "Actual Budget rejected the password. The generated server password is in Settings → Passwords; if this server kept an older password (changed in Actual, or data from an earlier install), type it into ACTUAL_PASSWORD in Configure." - If no budgets found: "No budgets found. Create a budget in Actual Budget first, then set ACTUAL_SYNC_ID if you have multiple budgets." - If a query returns empty: the budget may not have data for that period yet diff --git a/bundles/audiobookshelf/.env.example b/bundles/audiobookshelf/.env.example index d8b9be8de..3a4b15f05 100644 --- a/bundles/audiobookshelf/.env.example +++ b/bundles/audiobookshelf/.env.example @@ -4,5 +4,5 @@ AUDIOBOOKSHELF_URL=http://localhost:13378 AUDIOBOOKSHELF_API_KEY= # Docker mode only — host paths for media -AUDIOBOOKSHELF_AUDIOBOOK_PATH=/path/to/audiobooks -AUDIOBOOKSHELF_PODCAST_PATH=/path/to/podcasts +# AUDIOBOOKSHELF_AUDIOBOOK_PATH=/path/to/audiobooks (example — set your own) +# AUDIOBOOKSHELF_PODCAST_PATH=/path/to/podcasts (example — set your own) diff --git a/bundles/audiobookshelf/manifest.json b/bundles/audiobookshelf/manifest.json index 9f3afb12a..398f51eaa 100644 --- a/bundles/audiobookshelf/manifest.json +++ b/bundles/audiobookshelf/manifest.json @@ -1,7 +1,7 @@ { "id": "audiobookshelf", "name": "Audiobookshelf", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted audiobook and podcast server — search, browse, and track listening progress", "type": "bundle", "author": "Crow", @@ -17,8 +17,8 @@ "env_vars": [ { "name": "AUDIOBOOKSHELF_URL", "description": "Audiobookshelf server URL", "default": "http://localhost:13378", "required": true }, { "name": "AUDIOBOOKSHELF_API_KEY", "description": "Audiobookshelf API token (Settings > Users > your user)", "required": true, "secret": true }, - { "name": "AUDIOBOOKSHELF_AUDIOBOOK_PATH", "description": "Host path for audiobook files (Docker mode)", "required": false }, - { "name": "AUDIOBOOKSHELF_PODCAST_PATH", "description": "Host path for podcast files (Docker mode)", "required": false } + { "name": "AUDIOBOOKSHELF_AUDIOBOOK_PATH", "description": "Host path for audiobook files (Docker mode).", "required": false }, + { "name": "AUDIOBOOKSHELF_PODCAST_PATH", "description": "Host path for podcast files (Docker mode).", "required": false } ], "ports": [13378], "webUI": { "port": 13378, "path": "/", "label": "Audiobookshelf" }, diff --git a/bundles/calibre-server/.env.example b/bundles/calibre-server/.env.example index 6f13772ab..366d0f0ea 100644 --- a/bundles/calibre-server/.env.example +++ b/bundles/calibre-server/.env.example @@ -3,7 +3,7 @@ CALIBRE_URL=http://localhost:8081 # Host path to your Calibre library folder (required for Docker mode) -CALIBRE_LIBRARY_PATH=/path/to/calibre/library +# CALIBRE_LIBRARY_PATH=/path/to/calibre/library (example — set your own) # HTTP Basic auth credentials (if authentication is enabled on the server) CALIBRE_USERNAME= diff --git a/bundles/calibre-server/manifest.json b/bundles/calibre-server/manifest.json index b864e96a0..8281cbff1 100644 --- a/bundles/calibre-server/manifest.json +++ b/bundles/calibre-server/manifest.json @@ -1,7 +1,7 @@ { "id": "calibre-server", "name": "Calibre Server", - "version": "1.0.0", + "version": "1.0.1", "description": "OPDS content server for ebooks — search, browse, and download from your Calibre library", "type": "bundle", "author": "Crow", diff --git a/bundles/calibre-web/.env.example b/bundles/calibre-web/.env.example index 30c0db918..cd11f7a79 100644 --- a/bundles/calibre-web/.env.example +++ b/bundles/calibre-web/.env.example @@ -7,4 +7,4 @@ CALIBRE_WEB_USERNAME= CALIBRE_WEB_PASSWORD= # Host path to the directory containing your Calibre metadata.db (required for Docker mode) -CALIBRE_WEB_DB_PATH=/path/to/calibre/library +# CALIBRE_WEB_DB_PATH=/path/to/calibre/library (example — set your own) diff --git a/bundles/calibre-web/manifest.json b/bundles/calibre-web/manifest.json index 42eaac34c..4662eed3b 100644 --- a/bundles/calibre-web/manifest.json +++ b/bundles/calibre-web/manifest.json @@ -1,7 +1,7 @@ { "id": "calibre-web", "name": "Calibre-Web", - "version": "1.0.0", + "version": "1.0.1", "description": "Web-based ebook reader and library manager for Calibre", "type": "bundle", "author": "Crow", diff --git a/bundles/gotosocial/.env.example b/bundles/gotosocial/.env.example index 468cd5b33..17b213719 100644 --- a/bundles/gotosocial/.env.example +++ b/bundles/gotosocial/.env.example @@ -2,11 +2,11 @@ # Public domain dedicated to GoToSocial (must be a subdomain; subpath mounts # break ActivityPub federation because actors are URL-keyed). -GTS_HOST=gts.example.com +# GTS_HOST=gts.example.com (example — set your own) # Account handle domain. Usually the same as GTS_HOST. If different, you # must add a .well-known/webfinger delegation on the apex (caddy_set_wellknown). -GTS_ACCOUNT_DOMAIN=gts.example.com +# GTS_ACCOUNT_DOMAIN=gts.example.com (example — set your own) # Internal URL the Crow MCP server uses to reach GoToSocial. Leave as-is # when running over the shared crow-federation docker network. diff --git a/bundles/gotosocial/docker-compose.yml b/bundles/gotosocial/docker-compose.yml index 9d82fc3f2..47a5ae106 100644 --- a/bundles/gotosocial/docker-compose.yml +++ b/bundles/gotosocial/docker-compose.yml @@ -20,7 +20,7 @@ services: - default - crow-federation environment: - GTS_HOST: ${GTS_HOST} + GTS_HOST: ${GTS_HOST:?Set GTS_HOST to your public domain} GTS_ACCOUNT_DOMAIN: ${GTS_ACCOUNT_DOMAIN:-${GTS_HOST}} GTS_PROTOCOL: https GTS_PORT: "8080" diff --git a/bundles/gotosocial/manifest.json b/bundles/gotosocial/manifest.json index 741bdb3eb..8c8a911a8 100644 --- a/bundles/gotosocial/manifest.json +++ b/bundles/gotosocial/manifest.json @@ -1,7 +1,7 @@ { "id": "gotosocial", "name": "GoToSocial", - "version": "1.0.1", + "version": "1.0.2", "description": "Lightweight ActivityPub microblog server (fediverse). Pi-friendly single-binary alternative to Mastodon — joins the public fediverse with a sub-500 MB footprint.", "type": "bundle", "author": "Crow", @@ -23,7 +23,7 @@ "es": "GoToSocial se une al fediverso: tu instancia será públicamente direccionable bajo el dominio que configures, y todo el contenido que publiques se replica a otros servidores ActivityPub. El contenido replicado no puede recuperarse completamente — las eliminaciones pueden no llegar a todos los servidores que guardaron tu publicación. La instancia obtiene y guarda contenido e imágenes de perfil de otros servidores federados, que pueden incluir material objetable. Eres responsable de moderar tu instancia y cumplir con la ley de tu jurisdicción. Si tu instancia es reportada por abuso, los nodos principales (mastodon.social, matrix.org) pueden dejar de federarse con tu dominio — y un dominio envenenado no puede rehabilitarse fácilmente; puede que tengas que mudarte a un dominio nuevo. La limpieza automática de medios (14 días por defecto, 7 días en hosts tipo Pi) está activa desde el primer día; los cachés de medios remotos igualmente crecen bajo federación activa." }, "requires": { - "env": ["GTS_HOST", "GTS_ACCOUNT_DOMAIN"], + "env": ["GTS_HOST"], "bundles": ["caddy"], "min_ram_mb": 512, "recommended_ram_mb": 1024, @@ -33,13 +33,13 @@ "env_vars": [ { "name": "GTS_HOST", - "description": "Public domain for this GoToSocial instance (e.g., gts.example.com). Must be a subdomain dedicated to GoToSocial — ActivityPub actors are URL-keyed and subpath mounts break federation.", + "description": "Public domain for this GoToSocial instance (e.g., gts.example.com). Must be a subdomain dedicated to GoToSocial — ActivityPub actors are URL-keyed and subpath mounts break federation. Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", "required": true }, { "name": "GTS_ACCOUNT_DOMAIN", - "description": "Account domain used in @user@domain handles. Usually the same as GTS_HOST. If your apex is example.com and GTS_HOST=gts.example.com, set this to example.com and add a .well-known/webfinger delegation on the apex.", - "required": true + "description": "Account domain used in @user@domain handles. Usually the same as GTS_HOST. If your apex is example.com and GTS_HOST=gts.example.com, set this to example.com and add a .well-known/webfinger delegation on the apex. Leave it empty to use GTS_HOST. Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", + "required": false }, { "name": "GTS_URL", diff --git a/bundles/jellyfin/.env.example b/bundles/jellyfin/.env.example index 554cb8a42..b2c5ac6d9 100644 --- a/bundles/jellyfin/.env.example +++ b/bundles/jellyfin/.env.example @@ -5,5 +5,5 @@ JELLYFIN_API_KEY= JELLYFIN_USER_ID= # Docker mode only — host paths for media and config -JELLYFIN_MEDIA_PATH=/path/to/media +# JELLYFIN_MEDIA_PATH=/path/to/media (example — set your own) JELLYFIN_CONFIG_PATH= diff --git a/bundles/jellyfin/manifest.json b/bundles/jellyfin/manifest.json index 70bc87b3a..c64a0c9d6 100644 --- a/bundles/jellyfin/manifest.json +++ b/bundles/jellyfin/manifest.json @@ -1,7 +1,7 @@ { "id": "jellyfin", "name": "Jellyfin", - "version": "1.0.1", + "version": "1.0.2", "description": "Self-hosted media server — stream movies, TV shows, and music with AI-powered library management", "type": "bundle", "author": "Crow", @@ -19,7 +19,7 @@ { "name": "JELLYFIN_URL", "description": "Jellyfin server URL", "default": "http://localhost:8096", "required": true }, { "name": "JELLYFIN_API_KEY", "description": "Jellyfin API key (Dashboard > API Keys)", "required": true, "secret": true }, { "name": "JELLYFIN_USER_ID", "description": "Default user ID for library browsing", "required": false }, - { "name": "JELLYFIN_MEDIA_PATH", "description": "Host path for media files (Docker mode)", "required": true }, + { "name": "JELLYFIN_MEDIA_PATH", "description": "Host path for media files (Docker mode). Leave it empty to start with an empty library in Crow's folder (media/jellyfin); point it at your media later in Configure.", "required": true, "default_in_crow_home": "media/jellyfin", "propagate": false }, { "name": "JELLYFIN_CONFIG_PATH", "description": "Host path for config (Docker mode)", "required": false } ], "ports": [8096], diff --git a/bundles/kavita/.env.example b/bundles/kavita/.env.example index 79f6beeee..db010cc9c 100644 --- a/bundles/kavita/.env.example +++ b/bundles/kavita/.env.example @@ -5,4 +5,4 @@ KAVITA_USERNAME= KAVITA_PASSWORD= # Docker mode only — host path for manga/comics/ebooks -KAVITA_LIBRARY_PATH=/path/to/manga +# KAVITA_LIBRARY_PATH=/path/to/manga (example — set your own) diff --git a/bundles/kavita/manifest.json b/bundles/kavita/manifest.json index 47da40df7..4e09a7e02 100644 --- a/bundles/kavita/manifest.json +++ b/bundles/kavita/manifest.json @@ -1,7 +1,7 @@ { "id": "kavita", "name": "Kavita", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted manga, comics, and ebook reader — browse series, track reading progress", "type": "bundle", "author": "Crow", @@ -18,7 +18,7 @@ { "name": "KAVITA_URL", "description": "Kavita server URL", "default": "http://localhost:5000", "required": true }, { "name": "KAVITA_USERNAME", "description": "Kavita username for API authentication", "required": true }, { "name": "KAVITA_PASSWORD", "description": "Kavita password for API authentication", "required": true, "secret": true }, - { "name": "KAVITA_LIBRARY_PATH", "description": "Host path for manga/comics/ebooks (Docker mode)", "required": false } + { "name": "KAVITA_LIBRARY_PATH", "description": "Host path for manga/comics/ebooks (Docker mode).", "required": false } ], "ports": [5000], "webUI": { "port": 5000, "path": "/", "label": "Kavita" }, diff --git a/bundles/lemmy/docker-compose.yml b/bundles/lemmy/docker-compose.yml index 7acd873d9..f9a42b048 100644 --- a/bundles/lemmy/docker-compose.yml +++ b/bundles/lemmy/docker-compose.yml @@ -80,6 +80,12 @@ services: RUST_BACKTRACE: "full" LEMMY_DATABASE_URL: "postgres://lemmy:${LEMMY_DB_PASSWORD}@postgres:5432/lemmy" LEMMY_CONFIG_LOCATION: /config/config.hjson + # Secrets reach the entrypoint through the environment and are expanded by its + # shell ($${...}), so the script text that stays in PID 1's command line (init: true) + # holds variable names, never values. + CROW_LEMMY_DB_PASSWORD: "${LEMMY_DB_PASSWORD}" + CROW_LEMMY_PICTRS_API_KEY: "${LEMMY_PICTRS_API_KEY}" + CROW_LEMMY_ADMIN_PASSWORD: "${LEMMY_ADMIN_PASSWORD}" volumes: - ./config:/config:ro entrypoint: @@ -98,11 +104,11 @@ services: port: 5432 database: lemmy user: lemmy - password: "${LEMMY_DB_PASSWORD}" + password: "$${CROW_LEMMY_DB_PASSWORD}" } pictrs: { url: "http://pictrs:8080/" - api_key: "${LEMMY_PICTRS_API_KEY}" + api_key: "$${CROW_LEMMY_PICTRS_API_KEY}" } federation: { enabled: ${LEMMY_FEDERATION_ENABLED:-true} @@ -110,7 +116,7 @@ services: } setup: { admin_username: "admin_pending" - admin_password: "change_me_via_web_setup" + admin_password: "$${CROW_LEMMY_ADMIN_PASSWORD}" site_name: "Crow Lemmy" } captcha: { enabled: true, difficulty: medium } diff --git a/bundles/lemmy/manifest.json b/bundles/lemmy/manifest.json index 303ef4509..2093ddcbd 100644 --- a/bundles/lemmy/manifest.json +++ b/bundles/lemmy/manifest.json @@ -1,7 +1,7 @@ { "id": "lemmy", "name": "Lemmy", - "version": "1.0.2", + "version": "1.0.3", "description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.", "type": "bundle", "author": "Crow", @@ -34,6 +34,7 @@ { "name": "LEMMY_HOSTNAME", "description": "Public domain (subdomain; path-mounts break ActivityPub).", "required": true }, { "name": "LEMMY_DB_PASSWORD", "description": "Password for the bundled Postgres role. Generated by Crow at install; never shown.", "required": true, "secret": true, "generate": "secret" }, { "name": "LEMMY_PICTRS_API_KEY", "description": "Shared secret Lemmy uses to talk to pict-rs. Generated by Crow at install; never shown.", "required": true, "secret": true, "generate": "secret" }, + { "name": "LEMMY_ADMIN_PASSWORD", "description": "Password of the first Lemmy admin (username admin_pending). Crow generates it at install and saves it in Settings → Passwords (this machine only); Lemmy creates the admin with it on its first successful start. (This bundle's container does not start yet — see the notes.)", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "admin password", "keychain_username": "admin_pending" }, { "name": "LEMMY_JWT", "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", "required": false, "secret": true }, { "name": "LEMMY_URL", "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false }, { "name": "LEMMY_OPEN_REGISTRATION", "description": "Allow new user signups (true/false). Default false — opens registration only after moderation tooling is configured.", "default": "false", "required": false }, @@ -41,5 +42,5 @@ ], "ports": [], "webUI": null, - "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed)." + "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed). Known issue: the entrypoint runs a path the pinned Lemmy image does not have, so the container does not start yet; a fix needs a live test." } diff --git a/bundles/lemmy/scripts/post-install.sh b/bundles/lemmy/scripts/post-install.sh index ab521132f..445309949 100755 --- a/bundles/lemmy/scripts/post-install.sh +++ b/bundles/lemmy/scripts/post-install.sh @@ -51,8 +51,8 @@ Lemmy stack is up. Next steps: 2. Open https://${LEMMY_HOSTNAME:-}/ and complete the setup wizard (admin username + password + site name). The compose - entrypoint writes a placeholder admin_pending account; the wizard - replaces it. + entrypoint creates a first admin, admin_pending, with the password + Crow generated at install (Settings -> Passwords). 3. Obtain a JWT: curl -X POST https://${LEMMY_HOSTNAME:-}/api/v3/user/login \\ diff --git a/bundles/lemmy/skills/lemmy.md b/bundles/lemmy/skills/lemmy.md index 0aaf98816..5e7198aac 100644 --- a/bundles/lemmy/skills/lemmy.md +++ b/bundles/lemmy/skills/lemmy.md @@ -44,7 +44,7 @@ Gated by F.0's hardware check. Refused below **1 GB effective RAM after committe profile: "activitypub" } ``` -2. Open https://lemmy.example.com/ and complete the setup wizard (admin username + password + site name). The compose entrypoint writes a placeholder `admin_pending` account; the web wizard replaces it. +2. Open https://lemmy.example.com/ and complete the setup wizard (admin username + password + site name). The compose entrypoint creates a first admin, `admin_pending`, with a password Crow generated at install (Settings → Passwords); log in with it if the wizard does not appear, and rename or replace the account. 3. Obtain an auth JWT: ```bash curl -X POST https://lemmy.example.com/api/v3/user/login \ diff --git a/bundles/maker-lab-advanced/docker-compose.yml b/bundles/maker-lab-advanced/docker-compose.yml index 7831bc866..c341489f0 100644 --- a/bundles/maker-lab-advanced/docker-compose.yml +++ b/bundles/maker-lab-advanced/docker-compose.yml @@ -9,7 +9,7 @@ services: - ./config/jupyterhub_config.py:/srv/jupyterhub/jupyterhub_config.py:ro - ./scripts/bootstrap-admin.py:/srv/jupyterhub/bootstrap-admin.py:ro environment: - - MLA_ADMIN_USER=${MLA_ADMIN_USER:?Set MLA_ADMIN_USER} + - MLA_ADMIN_USER=${MLA_ADMIN_USER:-admin} - MLA_ADMIN_PASSWORD=${MLA_ADMIN_PASSWORD:?Set MLA_ADMIN_PASSWORD} command: - "sh" diff --git a/bundles/maker-lab-advanced/manifest.json b/bundles/maker-lab-advanced/manifest.json index 34b073278..1a4137ec1 100644 --- a/bundles/maker-lab-advanced/manifest.json +++ b/bundles/maker-lab-advanced/manifest.json @@ -1,7 +1,7 @@ { "id": "maker-lab-advanced", "name": "Maker Lab Advanced", - "version": "0.1.1", + "version": "0.1.2", "description": "Self-hosted JupyterHub for older learners (ages 9+) — single-machine multi-user Python notebooks with kid-safe kernel defaults and an AI pair-programmer. Sibling bundle to maker-lab: Blockly is the 5-9 on-ramp, Scratch is the 8+ step-up, this is the 9+ landing zone with real Python.", "type": "bundle", "author": "Crow", @@ -20,8 +20,8 @@ "bundles": ["maker-lab"] }, "env_vars": [ - { "name": "MLA_ADMIN_USER", "description": "Initial JupyterHub admin username. Gets first login; add more admins from the UI after setup.", "required": true }, - { "name": "MLA_ADMIN_PASSWORD", "description": "Initial admin password. Set via NativeAuthenticator on first boot; change via the JupyterHub UI afterward.", "required": true, "secret": true }, + { "name": "MLA_ADMIN_USER", "description": "Initial JupyterHub admin username. Gets first login; add more admins from the UI after setup.", "required": true, "default": "admin" }, + { "name": "MLA_ADMIN_PASSWORD", "description": "Initial admin password. Crow generates it at install and saves it in Settings → Passwords (this machine only); NativeAuthenticator sets it on first boot. Change it in the JupyterHub UI afterward if you like.", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "admin password", "keychain_username": "${MLA_ADMIN_USER}" }, { "name": "MLA_HTTP_PORT", "description": "Host port for the JupyterHub UI. Default 8088.", "default": "8088", "required": false }, { "name": "MLA_DATA_PATH", "description": "Host path for user home dirs + JupyterHub state (defaults to a named volume).", "required": false } ], diff --git a/bundles/maker-lab-advanced/panel/maker-lab-advanced.js b/bundles/maker-lab-advanced/panel/maker-lab-advanced.js index 72d505020..55b3b10de 100644 --- a/bundles/maker-lab-advanced/panel/maker-lab-advanced.js +++ b/bundles/maker-lab-advanced/panel/maker-lab-advanced.js @@ -22,7 +22,7 @@ export default { const port = process.env.MLA_HTTP_PORT || "8088"; const hubUrl = `http://${req.hostname || "localhost"}:${port}`; - const adminUser = process.env.MLA_ADMIN_USER || "(MLA_ADMIN_USER unset — set in .env before first launch)"; + const adminUser = process.env.MLA_ADMIN_USER || "admin"; // Short liveness probe so the operator sees status at a glance. async function probe(url, timeoutMs) { @@ -58,8 +58,8 @@ export default {

First-boot checklist

    -
  1. Set MLA_ADMIN_USER and MLA_ADMIN_PASSWORD in .env. Restart the bundle if you changed them.
  2. -
  3. Navigate to ${escapeHtml(hubUrl)}/hub/signup, sign up with the admin username + password from step 1. Because that username is in the hub's admin_users list, the account auto-authorizes — no self-approval required.
  4. +
  5. Crow generated the admin password at install: it is in Settings → Passwords (username admin unless you set MLA_ADMIN_USER).
  6. +
  7. Open ${escapeHtml(hubUrl)}/hub/login and log in with that username and password: Crow created the admin account on first boot.
  8. Admin view at ${escapeHtml(hubUrl)}/hub/admin lists users + server status.
  9. Learner signups go to ${escapeHtml(hubUrl)}/hub/authorize for admin approval before they can spawn a server.
diff --git a/bundles/matrix-dendrite/.env.example b/bundles/matrix-dendrite/.env.example index 883605552..4865c0604 100644 --- a/bundles/matrix-dendrite/.env.example +++ b/bundles/matrix-dendrite/.env.example @@ -4,12 +4,12 @@ # (@alice:example.com). If you use .well-known delegation, set this to # your apex (example.com). If you run dendrite directly at a subdomain, # set to the subdomain (matrix.example.com). -MATRIX_SERVER_NAME=example.com +# MATRIX_SERVER_NAME=example.com (example — set your own) # Domain Caddy reverse-proxies the client-server API on. Usually a # subdomain (matrix.example.com) even when MATRIX_SERVER_NAME is the # apex — .well-known on the apex delegates to this. -MATRIX_HOST=matrix.example.com +# MATRIX_HOST=matrix.example.com (example — set your own) # Postgres password for the bundled database. Generate with: # openssl rand -base64 32 | tr -d '/+=' | head -c 40 @@ -22,7 +22,7 @@ MATRIX_URL=http://dendrite:8008 # Filled AFTER the admin account is registered (see scripts/post-install.sh # output). Obtain via POST /_matrix/client/v3/login. MATRIX_ACCESS_TOKEN= -MATRIX_USER_ID=@admin:example.com +# MATRIX_USER_ID=@admin:example.com (example — set your own) # Populated on first boot by the dendrite entrypoint (printed to logs). # Copy the "Registration shared secret: XXX" line here. diff --git a/bundles/matrix-dendrite/docker-compose.yml b/bundles/matrix-dendrite/docker-compose.yml index e48049f01..e9c1a45df 100644 --- a/bundles/matrix-dendrite/docker-compose.yml +++ b/bundles/matrix-dendrite/docker-compose.yml @@ -51,7 +51,7 @@ services: postgres: condition: service_healthy environment: - MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME} + MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME:?Set MATRIX_SERVER_NAME to your Matrix domain} MATRIX_POSTGRES_URL: "postgres://dendrite:${MATRIX_POSTGRES_PASSWORD}@postgres:5432/dendrite?sslmode=disable" DENDRITE_TRACE_HTTP: "0" # Generated by Crow at install (blank on older installs: first boot then makes one). diff --git a/bundles/matrix-dendrite/manifest.json b/bundles/matrix-dendrite/manifest.json index e2872d27a..fdb24746d 100644 --- a/bundles/matrix-dendrite/manifest.json +++ b/bundles/matrix-dendrite/manifest.json @@ -1,7 +1,7 @@ { "id": "matrix-dendrite", "name": "Matrix (Dendrite)", - "version": "1.0.3", + "version": "1.0.4", "description": "Matrix homeserver on Dendrite — federated real-time chat with end-to-end encryption. Lighter than Synapse, monolithic binary. Joins the Matrix federation graph via HTTPS federation (:8448 or .well-known delegation).", "type": "bundle", "author": "Crow", @@ -33,7 +33,7 @@ "env_vars": [ { "name": "MATRIX_SERVER_NAME", - "description": "The homeserver's public domain — this is the server_name that appears in user IDs (@alice:example.com). Typically an apex (example.com) with a .well-known/matrix/server delegation, OR a subdomain you'll expose on :8448 directly (matrix.example.com).", + "description": "The homeserver's public domain — this is the server_name that appears in user IDs (@alice:example.com). Typically an apex (example.com) with a .well-known/matrix/server delegation, OR a subdomain you'll expose on :8448 directly (matrix.example.com). Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", "required": true }, { diff --git a/bundles/miniflux/manifest.json b/bundles/miniflux/manifest.json index 18aa26770..045ae7bad 100644 --- a/bundles/miniflux/manifest.json +++ b/bundles/miniflux/manifest.json @@ -1,7 +1,7 @@ { "id": "miniflux", "name": "Miniflux", - "version": "1.0.2", + "version": "1.0.3", "description": "Minimalist RSS reader — subscribe to feeds, read articles, and track news", "type": "bundle", "author": "Crow", @@ -18,10 +18,10 @@ { "name": "MINIFLUX_URL", "description": "Miniflux server URL", "default": "http://localhost:8085", "required": true }, { "name": "MINIFLUX_API_KEY", "description": "Miniflux API key (Settings > API Keys)", "required": true, "secret": true }, { "name": "MINIFLUX_ADMIN_USER", "description": "Admin username for Docker setup", "default": "admin", "required": false }, - { "name": "MINIFLUX_ADMIN_PASSWORD", "description": "Admin password for Docker setup", "required": true, "secret": true }, + { "name": "MINIFLUX_ADMIN_PASSWORD", "description": "Admin password for the Miniflux web app. Crow generates it at install and saves it in Settings → Passwords (this machine only); Miniflux creates the admin with it on first start.", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "admin password", "keychain_username": "${MINIFLUX_ADMIN_USER}", "keychain_url": "${MINIFLUX_URL}" }, { "name": "MINIFLUX_DB_PASSWORD", "description": "PostgreSQL password (Docker setup). Generated by Crow at install; never shown.", "required": false, "secret": true, "generate": "secret" } ], "ports": [8085], "webUI": { "port": 8085, "path": "/", "label": "Miniflux" }, - "notes": "Self-host via Docker (set MINIFLUX_ADMIN_PASSWORD) or connect to existing (set MINIFLUX_URL + MINIFLUX_API_KEY)." + "notes": "Self-host via Docker (Crow generates the admin password and saves it in Settings → Passwords) or connect to an existing server (set MINIFLUX_URL + MINIFLUX_API_KEY). Either way, create an API key in Miniflux (Settings → API Keys) and paste it into MINIFLUX_API_KEY." } diff --git a/bundles/navidrome/.env.example b/bundles/navidrome/.env.example index 5b76c9cf3..9581b4d83 100644 --- a/bundles/navidrome/.env.example +++ b/bundles/navidrome/.env.example @@ -5,4 +5,4 @@ NAVIDROME_USERNAME= NAVIDROME_PASSWORD= # Docker mode only — host path for music files -NAVIDROME_MUSIC_PATH=/path/to/music +# NAVIDROME_MUSIC_PATH=/path/to/music (example — set your own) diff --git a/bundles/navidrome/manifest.json b/bundles/navidrome/manifest.json index a99b8451b..370f058ef 100644 --- a/bundles/navidrome/manifest.json +++ b/bundles/navidrome/manifest.json @@ -1,7 +1,7 @@ { "id": "navidrome", "name": "Navidrome", - "version": "1.0.1", + "version": "1.0.2", "description": "Lightweight music streaming server — browse albums, artists, playlists, and stream music", "type": "bundle", "author": "Crow", @@ -18,7 +18,7 @@ { "name": "NAVIDROME_URL", "description": "Navidrome server URL", "default": "http://localhost:4533", "required": true }, { "name": "NAVIDROME_USERNAME", "description": "Navidrome username", "required": true }, { "name": "NAVIDROME_PASSWORD", "description": "Navidrome password", "required": true, "secret": true }, - { "name": "NAVIDROME_MUSIC_PATH", "description": "Host path for music files (Docker mode)", "required": true } + { "name": "NAVIDROME_MUSIC_PATH", "description": "Host path for music files (Docker mode). Leave it empty to start with an empty library in Crow's folder (media/music); point it at your music later in Configure.", "required": true, "default_in_crow_home": "media/music", "propagate": false } ], "ports": [4533], "webUI": { "port": 4533, "path": "/", "label": "Navidrome" } diff --git a/bundles/nextcloud/.env.example b/bundles/nextcloud/.env.example index fc2a63025..bb8c92fec 100644 --- a/bundles/nextcloud/.env.example +++ b/bundles/nextcloud/.env.example @@ -7,5 +7,5 @@ NEXTCLOUD_PASSWORD= NEXTCLOUD_MOUNT_PATH=/mnt/nextcloud # Database passwords (only needed for new deployment via docker-compose) -NEXTCLOUD_DB_PASSWORD=changeme -NEXTCLOUD_DB_ROOT_PASSWORD=rootchangeme +NEXTCLOUD_DB_PASSWORD= +NEXTCLOUD_DB_ROOT_PASSWORD= diff --git a/bundles/nextcloud/manifest.json b/bundles/nextcloud/manifest.json index 10675b32c..4161266a1 100644 --- a/bundles/nextcloud/manifest.json +++ b/bundles/nextcloud/manifest.json @@ -1,7 +1,7 @@ { "id": "nextcloud", "name": "Nextcloud (connect existing)", - "version": "1.1.0", + "version": "1.1.1", "description": "Deprecated: connect Crow to a Nextcloud you already run, over WebDAV. To host your own files, documents and calendars, install Crow Workspace instead.", "type": "skill", "author": "Crow", diff --git a/bundles/plex/.env.example b/bundles/plex/.env.example index 1d4d6bef3..f232ef1d2 100644 --- a/bundles/plex/.env.example +++ b/bundles/plex/.env.example @@ -6,6 +6,6 @@ PLEX_TOKEN= PLEX_MACHINE_ID= # Docker self-host mode only -PLEX_MEDIA_PATH=/path/to/media +# PLEX_MEDIA_PATH=/path/to/media (example — set your own) PLEX_CONFIG_PATH= PLEX_CLAIM_TOKEN= diff --git a/bundles/plex/manifest.json b/bundles/plex/manifest.json index c8d766d2b..5e4c40ed5 100644 --- a/bundles/plex/manifest.json +++ b/bundles/plex/manifest.json @@ -1,7 +1,7 @@ { "id": "plex", "name": "Plex", - "version": "1.0.2", + "version": "1.0.3", "privileged": true, "install_consent_messages": { "en": "Crow does not start a Plex server at install: it runs only if you start it from Extensions. If you do, the self-hosted Plex server uses host networking (Plex's own recommendation) so devices on your local network can discover it. It listens on every network interface of this machine (port 32400 and Plex's discovery ports) and mounts your media folder read-write. Until you claim it with your Plex account (PLEX_CLAIM_TOKEN), the first person who opens it can claim it.", diff --git a/bundles/skill-history.json b/bundles/skill-history.json index 08a329558..5a9b1d110 100644 --- a/bundles/skill-history.json +++ b/bundles/skill-history.json @@ -1,6 +1,7 @@ { "actual-budget/skills/actual-budget.md": [ - "3dc1033813587d7c5baa77bb6962f35897dc7b989d7411c2159009dbef1aaa17" + "3dc1033813587d7c5baa77bb6962f35897dc7b989d7411c2159009dbef1aaa17", + "cb10508902a01a2b6787f4b252fa1d6050f96d22606264d5a4e49b519c219c17" ], "adguard-home/skills/adguard-home.md": [ "27c0d0bc676111b0a62945bd2fa449dd89fb29b7b6bc53bd3081f277f0d67c4b" @@ -129,7 +130,8 @@ "f90e3bc25edb4e0536233a77d5baf5790424a091dda627d809c7263c024fee39" ], "lemmy/skills/lemmy.md": [ - "8159d8d03f02023153a8b78a885c92ed45d76bd163fee5f547487b400fb3529f" + "8159d8d03f02023153a8b78a885c92ed45d76bd163fee5f547487b400fb3529f", + "4da3ec3fd92fb5b03ddb54823c4d7147a6da63f76e30b6ab8c5334494c1f83aa" ], "linkding/skills/linkding.md": [ "72ab294aa4ba9d905e8ccf81212e14290c7c3a864c5ab3f6119b2ca6e65526c6" diff --git a/bundles/tax/.env.example b/bundles/tax/.env.example index 149e8bf91..9088cffad 100644 --- a/bundles/tax/.env.example +++ b/bundles/tax/.env.example @@ -1,10 +1,11 @@ # Crow Tax — Environment Variables # Required: Passphrase for encrypting PII (SSN, names) at rest -CROW_TAX_ENCRYPTION_KEY=change-this-to-a-strong-passphrase +# Set a passphrase of your own (keep a copy: without it saved returns cannot be read). +CROW_TAX_ENCRYPTION_KEY= # Optional: Path to tax database (defaults to Crow's main DB) # CROW_TAX_DB_PATH=/path/to/tax.db # Optional: VNC password for browser automation container -# VNC_PASSWORD=crowtax +# VNC_PASSWORD= diff --git a/bundles/tax/manifest.json b/bundles/tax/manifest.json index 85b8fcc5c..5b579e806 100644 --- a/bundles/tax/manifest.json +++ b/bundles/tax/manifest.json @@ -1,7 +1,7 @@ { "id": "tax", "name": "Tax Filing Assistant", - "version": "2.0.2", + "version": "2.0.3", "description": "Prepare federal income taxes — PDF document ingestion, calculation, form generation, and guided filing. Federal returns only (no state). Supports 1040, Schedules 1/C/D/SE, Form 8889, Form 8863, and common credits. 2024 + 2025 tax years.", "type": "mcp-server", "author": "Crow", @@ -11,7 +11,7 @@ "server": { "command": "node", "args": ["server/index.js"], - "envKeys": ["CROW_TAX_DB_PATH"] + "envKeys": ["CROW_TAX_DB_PATH", "CROW_TAX_ENCRYPTION_KEY", "CROW_TAX_ENCRYPTION_KEY_PREVIOUS"] }, "panel": "panel/tax.js", "panelRoutes": "panel/routes.js", @@ -28,8 +28,15 @@ }, { "name": "CROW_TAX_ENCRYPTION_KEY", - "description": "Passphrase for encrypting PII at rest (SSN, names). Set on first use.", + "description": "Passphrase that encrypts personal data at rest (SSN, names). Choose your own and keep a copy: without it saved returns can't be read. Changing it keeps the key it replaces in CROW_TAX_ENCRYPTION_KEY_PREVIOUS, and the tax server moves saved returns to the new key when it next starts. Restart it between two changes.", "required": true, + "secret": true, + "previous_key": "CROW_TAX_ENCRYPTION_KEY_PREVIOUS" + }, + { + "name": "CROW_TAX_ENCRYPTION_KEY_PREVIOUS", + "description": "Only after changing the passphrase: the old one, so returns saved with it can still be opened. Each return moves to the new passphrase when the tax server next starts or the return is saved; then this can be cleared.", + "required": false, "secret": true } ], diff --git a/bundles/tax/server/crypto.js b/bundles/tax/server/crypto.js index 1089b98c1..4e34fe9f5 100644 --- a/bundles/tax/server/crypto.js +++ b/bundles/tax/server/crypto.js @@ -5,7 +5,7 @@ * Key is derived from CROW_TAX_ENCRYPTION_KEY via PBKDF2. */ -import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from "node:crypto"; +import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "node:crypto"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; @@ -17,6 +17,25 @@ const ITERATIONS = 100000; /** * Derive a 256-bit key from a passphrase using PBKDF2. */ +// SHA-256 of the example key once published in this bundle's .env.example. +const PUBLISHED_EXAMPLE_KEYS = new Set(["113e3beef46e2a2ab77b0c3ea62ef37d823b82eab4f713a44af32bcdd2655365"]); + +/** The example passphrase once published in this repo (matched by hash only, so no real + * passphrase is ever refused for its shape). Only encryption refuses it; decrypt() still + * opens data an install saved with it. */ +export function isExampleKey(passphrase) { + return PUBLISHED_EXAMPLE_KEYS.has(createHash("sha256").update(String(passphrase).trim()).digest("hex")); +} + +/** Decrypt with the current passphrase, else the previous one (after a key change). */ +export function decryptWithFallback(encoded, passphrases) { + let last; + for (const p of passphrases.filter(Boolean)) { + try { return decrypt(encoded, p); } catch (err) { last = err; } + } + throw last || new Error("Encryption passphrase required (CROW_TAX_ENCRYPTION_KEY)"); +} + function deriveKey(passphrase, salt) { return pbkdf2Sync(passphrase, salt, ITERATIONS, KEY_LENGTH, "sha256"); } @@ -26,6 +45,9 @@ function deriveKey(passphrase, salt) { */ export function encrypt(plaintext, passphrase) { if (!passphrase) throw new Error("Encryption passphrase required (CROW_TAX_ENCRYPTION_KEY)"); + if (isExampleKey(passphrase)) { + throw new Error("CROW_TAX_ENCRYPTION_KEY is the example value published in Crow's repository. Set a passphrase of your own in Extensions, and put the old one in CROW_TAX_ENCRYPTION_KEY_PREVIOUS so returns saved with it stay readable (they move to the new passphrase when the tax server next starts, or when each is saved)."); + } const salt = randomBytes(SALT_LENGTH); const key = deriveKey(passphrase, salt); diff --git a/bundles/tax/server/server.js b/bundles/tax/server/server.js index db7cd19f7..d9f4824d5 100644 --- a/bundles/tax/server/server.js +++ b/bundles/tax/server/server.js @@ -10,7 +10,7 @@ import { z } from "zod"; import { randomBytes } from "node:crypto"; import { createDbClient } from "./db.js"; import { initTaxTables } from "./init-tables.js"; -import { encrypt, decrypt } from "./crypto.js"; +import { encrypt, decrypt, decryptWithFallback } from "./crypto.js"; import { processReturn, loadTables, getFormLines, requiredForms, validate } from "../engine/index.js"; function genReturnId() { @@ -25,6 +25,26 @@ function getEncryptionKey() { return key; } +/** + * Re-encrypt every return that only `previous` opens with `current`. Best-effort: a row + * neither key opens is left as it is. Returns the number of rows moved. + */ +export async function reencryptToCurrentKey(db, current, previous) { + if (!current || !previous || current === previous) return 0; + let moved = 0; + let rows; + try { rows = (await db.execute("SELECT id, data FROM tax_returns")).rows; } catch { return 0; } + for (const r of rows) { + try { decrypt(r.data, current); continue; } catch { /* not on the current key */ } + let plain; + try { plain = decrypt(r.data, previous); } catch { continue; } + // Compare-and-swap: a save that landed after the read wins; this row is moved later. + const res = await db.execute({ sql: "UPDATE tax_returns SET data = ? WHERE id = ? AND data = ?", args: [encrypt(plain, current), r.id, r.data] }); + if ((res.rowsAffected ?? res.changes ?? 1) > 0) moved++; + } + return moved; +} + export function createTaxServer(dbPath, options = {}) { const server = new McpServer( { name: "crow-tax", version: "1.0.0" }, @@ -33,13 +53,19 @@ export function createTaxServer(dbPath, options = {}) { const db = createDbClient(dbPath); + // After a passphrase change: re-encrypt every return that only the previous key opens, + // so the previous key can be cleared (and changed again) without stranding data. + reencryptToCurrentKey(db, process.env.CROW_TAX_ENCRYPTION_KEY, process.env.CROW_TAX_ENCRYPTION_KEY_PREVIOUS).catch(() => {}); + // Helper: load return from DB async function loadReturn(id) { const row = await db.execute({ sql: "SELECT * FROM tax_returns WHERE id = ?", args: [id] }); if (row.rows.length === 0) return null; const r = row.rows[0]; try { - const decrypted = decrypt(r.data, getEncryptionKey()); + // After a passphrase change, rows saved with the old one still open; saving + // re-encrypts them with the current passphrase. + const decrypted = decryptWithFallback(r.data, [getEncryptionKey(), process.env.CROW_TAX_ENCRYPTION_KEY_PREVIOUS]); return { ...r, data: JSON.parse(decrypted), result: r.result ? JSON.parse(r.result) : null }; } catch { return { ...r, data: JSON.parse(r.data), result: r.result ? JSON.parse(r.result) : null }; diff --git a/bundles/writefreely/.env.example b/bundles/writefreely/.env.example index 1f9461fd8..2b15ee783 100644 --- a/bundles/writefreely/.env.example +++ b/bundles/writefreely/.env.example @@ -2,7 +2,7 @@ # Public domain dedicated to WriteFreely (subdomain; ActivityPub actors # are URL-keyed so subpath mounts break federation). -WF_HOST=blog.example.com +# WF_HOST=blog.example.com (example — set your own) # Internal URL the Crow MCP server uses. Leave as-is when running over # the shared crow-federation docker network. diff --git a/bundles/writefreely/docker-compose.yml b/bundles/writefreely/docker-compose.yml index 07912e5d5..3ebcb5bda 100644 --- a/bundles/writefreely/docker-compose.yml +++ b/bundles/writefreely/docker-compose.yml @@ -22,7 +22,7 @@ services: # WriteFreely reads its config from /go/keys/config.ini. The # entrypoint below seeds that file on first run using the env # vars below, then execs into writefreely. - WF_HOST: ${WF_HOST} + WF_HOST: ${WF_HOST:?Set WF_HOST to your public domain} WF_SINGLE_USER: ${WF_SINGLE_USER:-true} WF_DB_TYPE: sqlite3 WF_DB_FILENAME: /go/keys/writefreely.db diff --git a/bundles/writefreely/manifest.json b/bundles/writefreely/manifest.json index aad55d14e..8297a36cb 100644 --- a/bundles/writefreely/manifest.json +++ b/bundles/writefreely/manifest.json @@ -1,7 +1,7 @@ { "id": "writefreely", "name": "WriteFreely", - "version": "1.0.2", + "version": "1.0.3", "description": "Lightweight, minimalist ActivityPub blogging platform. Single-binary, single-SQLite-file footprint — ideal for long-form writing federated to the fediverse. Complements GoToSocial (microblog) and Crow's own blog.", "type": "bundle", "author": "Crow", @@ -33,7 +33,7 @@ "env_vars": [ { "name": "WF_HOST", - "description": "Public domain for this WriteFreely instance (must be a subdomain; subpath mounts break ActivityPub).", + "description": "Public domain for this WriteFreely instance (must be a subdomain; subpath mounts break ActivityPub). Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", "required": true }, { diff --git a/docs/es/integrations/actual-budget.md b/docs/es/integrations/actual-budget.md index 2f7e3ceea..9509ad433 100644 --- a/docs/es/integrations/actual-budget.md +++ b/docs/es/integrations/actual-budget.md @@ -27,16 +27,7 @@ Instala Actual Budget como un bundle de Crow. Esto ejecuta Actual Budget en Dock O instálalo desde el panel de **Extensiones** en el Crow's Nest. -Después de la instalación, Actual Budget estará disponible en `http://tu-servidor:5006`. En tu primera visita a la interfaz web, se te pedirá establecer una contraseña del servidor. Usa la misma contraseña que `ACTUAL_PASSWORD` en tu archivo `.env`: - -```bash -# En tu archivo .env -ACTUAL_PASSWORD=tu-contrasena-del-servidor -``` - -Reinicia el bundle para que los cambios surtan efecto: - -> "Crow, reinicia el bundle de Actual Budget" +Después de la instalación, Actual Budget estará disponible en `http://tu-servidor:5006`. Crow genera la contraseña del servidor, la establece en el servidor nuevo justo después de instalar y la guarda en **Ajustes** > **Contraseñas** (solo en esta máquina). Inicia sesión en Actual con ella y crea un presupuesto. ### Opción B: Conectar a Actual Budget existente @@ -89,7 +80,7 @@ Asegúrate de que la `ACTUAL_URL` sea accesible desde la máquina que ejecuta Cr ### "Login fallido" o error de autenticación -La `ACTUAL_PASSWORD` debe coincidir con la contraseña del servidor que estableciste durante la configuración inicial en la interfaz web de Actual. Si cambiaste la contraseña en la interfaz web, actualiza tu archivo `.env` para que coincida. +Para el servidor que instala Crow, Crow usa la contraseña generada en **Ajustes** > **Contraseñas**. Si la cambiaste en la interfaz web de Actual, o el servidor conservó sus datos (y su contraseña) de una instalación anterior, escribe la contraseña actual en `ACTUAL_PASSWORD` en Configurar. Para un servidor que ejecutas en otro lugar, `ACTUAL_PASSWORD` debe coincidir con su contraseña. ### No aparecen datos del presupuesto diff --git a/docs/es/integrations/jellyfin.md b/docs/es/integrations/jellyfin.md index 96dbf1bfe..d52cd4094 100644 --- a/docs/es/integrations/jellyfin.md +++ b/docs/es/integrations/jellyfin.md @@ -26,7 +26,7 @@ Instala Jellyfin como un bundle de Crow. Esto ejecuta Jellyfin en Docker junto a O instálalo desde el panel de **Extensiones** en el Crow's Nest. -Después de la instalación, configura la ruta a tus archivos multimedia: +Si lo dejas vacío al instalar, Jellyfin empieza con una biblioteca vacía en la carpeta de Crow (`media/jellyfin`). Para usar tus archivos, configura la ruta en Configurar (o en el `.env` del bundle): ```bash # En tu archivo .env diff --git a/docs/es/integrations/miniflux.md b/docs/es/integrations/miniflux.md index 8d4a85ec0..c49761da5 100644 --- a/docs/es/integrations/miniflux.md +++ b/docs/es/integrations/miniflux.md @@ -27,16 +27,7 @@ Instala Miniflux como un bundle de Crow. Esto ejecuta Miniflux con PostgreSQL en O instálalo desde el panel de **Extensiones** en el Crow's Nest. -Después de la instalación, configura tu contraseña de administrador: - -```bash -# En tu archivo .env -MINIFLUX_ADMIN_PASSWORD=tu-contraseña-segura -``` - -Reinicia el bundle para que los cambios surtan efecto: - -> "Crow, reinicia el bundle de Miniflux" +Crow genera la contraseña de administrador al instalar y la guarda en **Ajustes** > **Contraseñas** (solo en esta máquina; usuario `admin` salvo que definas `MINIFLUX_ADMIN_USER`). Miniflux estará disponible en `http://tu-servidor:8085`. Inicia sesión con la cuenta de administrador y luego genera una clave de API desde **Settings** > **API Keys**. diff --git a/docs/es/integrations/navidrome.md b/docs/es/integrations/navidrome.md index 8baface38..8deebbcea 100644 --- a/docs/es/integrations/navidrome.md +++ b/docs/es/integrations/navidrome.md @@ -27,7 +27,7 @@ Instala Navidrome como un bundle de Crow. Esto ejecuta Navidrome en Docker junto O instálalo desde el panel de **Extensiones** en el Crow's Nest. -Después de la instalación, configura la ruta a tu directorio de música: +Si lo dejas vacío al instalar, Navidrome empieza con una biblioteca vacía en la carpeta de Crow (`media/music`). Para usar tu música, configura la ruta en Configurar (o en el `.env` del bundle): ```bash # En tu archivo .env diff --git a/docs/integrations/actual-budget.md b/docs/integrations/actual-budget.md index ac0f74f04..fbf06080e 100644 --- a/docs/integrations/actual-budget.md +++ b/docs/integrations/actual-budget.md @@ -27,16 +27,7 @@ Install Actual Budget as a Crow bundle. This runs Actual Budget in Docker alongs Or install from the **Extensions** panel in the Crow's Nest. -After installation, Actual Budget will be available at `http://your-server:5006`. On your first visit to the web UI, you will be prompted to set a server password. Use the same password as `ACTUAL_PASSWORD` in your `.env` file: - -```bash -# In your .env file -ACTUAL_PASSWORD=your-server-password -``` - -Restart the bundle for changes to take effect: - -> "Crow, restart the Actual Budget bundle" +After installation, Actual Budget will be available at `http://your-server:5006`. Crow generates the server password, sets it on the new server right after install, and saves it in **Settings** > **Passwords** (this machine only). Log in to Actual with it and create a budget. ### Option B: Connect to existing Actual Budget @@ -89,7 +80,7 @@ Make sure the `ACTUAL_URL` is reachable from the machine running Crow. If Actual ### "Login failed" or authentication error -The `ACTUAL_PASSWORD` must match the server password you set during initial setup in Actual's web UI. If you changed the password in the web UI, update your `.env` file to match. +For the server Crow installs, Crow uses the generated password in **Settings** > **Passwords**. If you changed it in Actual's web UI, or the server kept its data (and password) from an earlier install, type the current password into `ACTUAL_PASSWORD` in Configure. For a server you run elsewhere, `ACTUAL_PASSWORD` must match its password. ### No budget data showing up diff --git a/docs/integrations/jellyfin.md b/docs/integrations/jellyfin.md index 396ded285..f68e3e069 100644 --- a/docs/integrations/jellyfin.md +++ b/docs/integrations/jellyfin.md @@ -26,7 +26,7 @@ Install Jellyfin as a Crow bundle. This runs Jellyfin in Docker alongside your C Or install from the **Extensions** panel in the Crow's Nest. -After installation, set the path to your media files: +Left empty at install, Jellyfin starts with an empty library in Crow's folder (`media/jellyfin`). To use your own media, set the path in Configure (or in the bundle's `.env`): ```bash # In your .env file diff --git a/docs/integrations/miniflux.md b/docs/integrations/miniflux.md index 6756b1b42..da0d60125 100644 --- a/docs/integrations/miniflux.md +++ b/docs/integrations/miniflux.md @@ -27,16 +27,7 @@ Install Miniflux as a Crow bundle. This runs Miniflux with PostgreSQL in Docker Or install from the **Extensions** panel in the Crow's Nest. -After installation, set your admin password: - -```bash -# In your .env file -MINIFLUX_ADMIN_PASSWORD=your-secure-password -``` - -Restart the bundle for changes to take effect: - -> "Crow, restart the Miniflux bundle" +Crow generates the admin password at install and saves it in **Settings** > **Passwords** (this machine only; username `admin` unless you set `MINIFLUX_ADMIN_USER`). Miniflux will be available at `http://your-server:8085`. Log in with the admin account, then generate an API key from **Settings** > **API Keys**. diff --git a/docs/integrations/navidrome.md b/docs/integrations/navidrome.md index 6b1538129..bf90ebde6 100644 --- a/docs/integrations/navidrome.md +++ b/docs/integrations/navidrome.md @@ -27,7 +27,7 @@ Install Navidrome as a Crow bundle. This runs Navidrome in Docker alongside your Or install from the **Extensions** panel in the Crow's Nest. -After installation, set the path to your music directory: +Left empty at install, Navidrome starts with an empty library in Crow's folder (`media/music`). To use your own music, set the path in Configure (or in the bundle's `.env`): ```bash # In your .env file diff --git a/registry/add-ons.json b/registry/add-ons.json index 7bbd44a92..b0de393b9 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -4,7 +4,7 @@ { "id": "actual-budget", "name": "Actual Budget", - "version": "1.0.0", + "version": "1.0.1", "description": "Privacy-first personal finance — track spending, manage budgets, and view financial reports", "type": "bundle", "author": "Crow", @@ -27,7 +27,8 @@ ], "envKeys": [ "ACTUAL_URL", - "ACTUAL_PASSWORD" + "ACTUAL_PASSWORD", + "ACTUAL_SERVER_PASSWORD" ] }, "panel": "panel/actual-budget.js", @@ -36,12 +37,13 @@ "skills/actual-budget.md" ], "requires": { - "env": [ - "ACTUAL_PASSWORD" - ], "min_ram_mb": 256, "min_disk_mb": 200 }, + "postInstall": { + "script": "scripts/set-server-password.sh", + "timeout_s": 180 + }, "env_vars": [ { "name": "ACTUAL_URL", @@ -51,10 +53,20 @@ }, { "name": "ACTUAL_PASSWORD", - "description": "Actual server password", - "required": true, + "description": "Password of an Actual server you already run elsewhere. Leave it empty for the Actual server Crow installs on this machine: Crow generates that one's password.", + "required": false, "secret": true }, + { + "name": "ACTUAL_SERVER_PASSWORD", + "description": "Password of the Actual server Crow installs on this machine. Crow generates it, saves it in Settings → Passwords (this machine only) and sets it on the new server right after install. Log in to Actual with it.", + "required": true, + "secret": true, + "generate": "secret", + "keychain": true, + "keychain_label": "server password", + "keychain_url": "http://localhost:5006/" + }, { "name": "ACTUAL_SYNC_ID", "description": "Budget sync ID to auto-select budget", @@ -69,7 +81,7 @@ "path": "/", "label": "Actual Budget" }, - "notes": "Privacy-first budgeting. Data stays local. Set ACTUAL_PASSWORD to match your Actual server password.", + "notes": "Privacy-first budgeting. Data stays local. Crow sets a generated password on the Actual server it installs (Settings → Passwords). To use an Actual server you already run, set ACTUAL_URL and ACTUAL_PASSWORD instead.", "official": true }, { @@ -190,7 +202,7 @@ { "id": "audiobookshelf", "name": "Audiobookshelf", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted audiobook and podcast server — search, browse, and track listening progress", "type": "bundle", "author": "Crow", @@ -243,12 +255,12 @@ }, { "name": "AUDIOBOOKSHELF_AUDIOBOOK_PATH", - "description": "Host path for audiobook files (Docker mode)", + "description": "Host path for audiobook files (Docker mode).", "required": false }, { "name": "AUDIOBOOKSHELF_PODCAST_PATH", - "description": "Host path for podcast files (Docker mode)", + "description": "Host path for podcast files (Docker mode).", "required": false } ], @@ -541,7 +553,7 @@ { "id": "calibre-server", "name": "Calibre Server", - "version": "1.0.0", + "version": "1.0.1", "description": "OPDS content server for ebooks — search, browse, and download from your Calibre library", "type": "bundle", "author": "Crow", @@ -618,7 +630,7 @@ { "id": "calibre-web", "name": "Calibre-Web", - "version": "1.0.0", + "version": "1.0.1", "description": "Web-based ebook reader and library manager for Calibre", "type": "bundle", "author": "Crow", @@ -1743,7 +1755,7 @@ { "id": "gotosocial", "name": "GoToSocial", - "version": "1.0.1", + "version": "1.0.2", "description": "Lightweight ActivityPub microblog server (fediverse). Pi-friendly single-binary alternative to Mastodon — joins the public fediverse with a sub-500 MB footprint.", "type": "bundle", "author": "Crow", @@ -1781,8 +1793,7 @@ }, "requires": { "env": [ - "GTS_HOST", - "GTS_ACCOUNT_DOMAIN" + "GTS_HOST" ], "bundles": [ "caddy" @@ -1795,13 +1806,13 @@ "env_vars": [ { "name": "GTS_HOST", - "description": "Public domain for this GoToSocial instance (e.g., gts.example.com). Must be a subdomain dedicated to GoToSocial — ActivityPub actors are URL-keyed and subpath mounts break federation.", + "description": "Public domain for this GoToSocial instance (e.g., gts.example.com). Must be a subdomain dedicated to GoToSocial — ActivityPub actors are URL-keyed and subpath mounts break federation. Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", "required": true }, { "name": "GTS_ACCOUNT_DOMAIN", - "description": "Account domain used in @user@domain handles. Usually the same as GTS_HOST. If your apex is example.com and GTS_HOST=gts.example.com, set this to example.com and add a .well-known/webfinger delegation on the apex.", - "required": true + "description": "Account domain used in @user@domain handles. Usually the same as GTS_HOST. If your apex is example.com and GTS_HOST=gts.example.com, set this to example.com and add a .well-known/webfinger delegation on the apex. Leave it empty to use GTS_HOST. Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", + "required": false }, { "name": "GTS_URL", @@ -2061,7 +2072,7 @@ { "id": "jellyfin", "name": "Jellyfin", - "version": "1.0.1", + "version": "1.0.2", "description": "Self-hosted media server — stream movies, TV shows, and music with AI-powered library management", "type": "bundle", "author": "Crow", @@ -2121,8 +2132,10 @@ }, { "name": "JELLYFIN_MEDIA_PATH", - "description": "Host path for media files (Docker mode)", - "required": true + "description": "Host path for media files (Docker mode). Leave it empty to start with an empty library in Crow's folder (media/jellyfin); point it at your media later in Configure.", + "required": true, + "default_in_crow_home": "media/jellyfin", + "propagate": false }, { "name": "JELLYFIN_CONFIG_PATH", @@ -2144,7 +2157,7 @@ { "id": "kavita", "name": "Kavita", - "version": "1.0.0", + "version": "1.0.1", "description": "Self-hosted manga, comics, and ebook reader — browse series, track reading progress", "type": "bundle", "author": "Crow", @@ -2204,7 +2217,7 @@ }, { "name": "KAVITA_LIBRARY_PATH", - "description": "Host path for manga/comics/ebooks (Docker mode)", + "description": "Host path for manga/comics/ebooks (Docker mode).", "required": false } ], @@ -2505,7 +2518,7 @@ { "id": "lemmy", "name": "Lemmy", - "version": "1.0.2", + "version": "1.0.3", "description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.", "type": "bundle", "author": "Crow", @@ -2577,6 +2590,16 @@ "secret": true, "generate": "secret" }, + { + "name": "LEMMY_ADMIN_PASSWORD", + "description": "Password of the first Lemmy admin (username admin_pending). Crow generates it at install and saves it in Settings → Passwords (this machine only); Lemmy creates the admin with it on its first successful start. (This bundle's container does not start yet — see the notes.)", + "required": true, + "secret": true, + "generate": "secret", + "keychain": true, + "keychain_label": "admin password", + "keychain_username": "admin_pending" + }, { "name": "LEMMY_JWT", "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", @@ -2604,7 +2627,7 @@ ], "ports": [], "webUI": null, - "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed).", + "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed). Known issue: the entrypoint runs a path the pinned Lemmy image does not have, so the container does not start yet; a fix needs a live test.", "official": true }, { @@ -2941,7 +2964,7 @@ { "id": "maker-lab-advanced", "name": "Maker Lab Advanced", - "version": "0.1.1", + "version": "0.1.2", "description": "Self-hosted JupyterHub for older learners (ages 9+) — single-machine multi-user Python notebooks with kid-safe kernel defaults and an AI pair-programmer. Sibling bundle to maker-lab: Blockly is the 5-9 on-ramp, Scratch is the 8+ step-up, this is the 9+ landing zone with real Python.", "type": "bundle", "author": "Crow", @@ -2982,13 +3005,18 @@ { "name": "MLA_ADMIN_USER", "description": "Initial JupyterHub admin username. Gets first login; add more admins from the UI after setup.", - "required": true + "required": true, + "default": "admin" }, { "name": "MLA_ADMIN_PASSWORD", - "description": "Initial admin password. Set via NativeAuthenticator on first boot; change via the JupyterHub UI afterward.", + "description": "Initial admin password. Crow generates it at install and saves it in Settings → Passwords (this machine only); NativeAuthenticator sets it on first boot. Change it in the JupyterHub UI afterward if you like.", "required": true, - "secret": true + "secret": true, + "generate": "secret", + "keychain": true, + "keychain_label": "admin password", + "keychain_username": "${MLA_ADMIN_USER}" }, { "name": "MLA_HTTP_PORT", @@ -3296,7 +3324,7 @@ { "id": "matrix-dendrite", "name": "Matrix (Dendrite)", - "version": "1.0.3", + "version": "1.0.4", "description": "Matrix homeserver on Dendrite — federated real-time chat with end-to-end encryption. Lighter than Synapse, monolithic binary. Joins the Matrix federation graph via HTTPS federation (:8448 or .well-known delegation).", "type": "bundle", "author": "Crow", @@ -3351,7 +3379,7 @@ "env_vars": [ { "name": "MATRIX_SERVER_NAME", - "description": "The homeserver's public domain — this is the server_name that appears in user IDs (@alice:example.com). Typically an apex (example.com) with a .well-known/matrix/server delegation, OR a subdomain you'll expose on :8448 directly (matrix.example.com).", + "description": "The homeserver's public domain — this is the server_name that appears in user IDs (@alice:example.com). Typically an apex (example.com) with a .well-known/matrix/server delegation, OR a subdomain you'll expose on :8448 directly (matrix.example.com). Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", "required": true }, { @@ -3772,7 +3800,7 @@ { "id": "miniflux", "name": "Miniflux", - "version": "1.0.2", + "version": "1.0.3", "description": "Minimalist RSS reader — subscribe to feeds, read articles, and track news", "type": "bundle", "author": "Crow", @@ -3831,9 +3859,14 @@ }, { "name": "MINIFLUX_ADMIN_PASSWORD", - "description": "Admin password for Docker setup", + "description": "Admin password for the Miniflux web app. Crow generates it at install and saves it in Settings → Passwords (this machine only); Miniflux creates the admin with it on first start.", "required": true, - "secret": true + "secret": true, + "generate": "secret", + "keychain": true, + "keychain_label": "admin password", + "keychain_username": "${MINIFLUX_ADMIN_USER}", + "keychain_url": "${MINIFLUX_URL}" }, { "name": "MINIFLUX_DB_PASSWORD", @@ -3851,7 +3884,7 @@ "path": "/", "label": "Miniflux" }, - "notes": "Self-host via Docker (set MINIFLUX_ADMIN_PASSWORD) or connect to existing (set MINIFLUX_URL + MINIFLUX_API_KEY).", + "notes": "Self-host via Docker (Crow generates the admin password and saves it in Settings → Passwords) or connect to an existing server (set MINIFLUX_URL + MINIFLUX_API_KEY). Either way, create an API key in Miniflux (Settings → API Keys) and paste it into MINIFLUX_API_KEY.", "official": true }, { @@ -4032,7 +4065,7 @@ { "id": "navidrome", "name": "Navidrome", - "version": "1.0.1", + "version": "1.0.2", "description": "Lightweight music streaming server — browse albums, artists, playlists, and stream music", "type": "bundle", "author": "Crow", @@ -4092,8 +4125,10 @@ }, { "name": "NAVIDROME_MUSIC_PATH", - "description": "Host path for music files (Docker mode)", - "required": true + "description": "Host path for music files (Docker mode). Leave it empty to start with an empty library in Crow's folder (media/music); point it at your music later in Configure.", + "required": true, + "default_in_crow_home": "media/music", + "propagate": false } ], "ports": [ @@ -4171,7 +4206,7 @@ { "id": "nextcloud", "name": "Nextcloud (connect existing)", - "version": "1.1.0", + "version": "1.1.1", "description": "Deprecated: connect Crow to a Nextcloud you already run, over WebDAV. To host your own files, documents and calendars, install Crow Workspace instead.", "type": "skill", "author": "Crow", @@ -4822,7 +4857,7 @@ { "id": "plex", "name": "Plex", - "version": "1.0.2", + "version": "1.0.3", "privileged": true, "install_consent_messages": { "en": "Crow does not start a Plex server at install: it runs only if you start it from Extensions. If you do, the self-hosted Plex server uses host networking (Plex's own recommendation) so devices on your local network can discover it. It listens on every network interface of this machine (port 32400 and Plex's discovery ports) and mounts your media folder read-write. Until you claim it with your Plex account (PLEX_CLAIM_TOKEN), the first person who opens it can claim it.", @@ -5862,7 +5897,7 @@ { "id": "tax", "name": "Tax Filing Assistant", - "version": "2.0.2", + "version": "2.0.3", "description": "Prepare federal income taxes — PDF document ingestion, calculation, form generation, and guided filing. Federal returns only (no state). Supports 1040, Schedules 1/C/D/SE, Form 8889, Form 8863, and common credits. 2024 + 2025 tax years.", "type": "mcp-server", "author": "Crow", @@ -5881,7 +5916,9 @@ "server/index.js" ], "envKeys": [ - "CROW_TAX_DB_PATH" + "CROW_TAX_DB_PATH", + "CROW_TAX_ENCRYPTION_KEY", + "CROW_TAX_ENCRYPTION_KEY_PREVIOUS" ] }, "panel": "panel/tax.js", @@ -5901,8 +5938,15 @@ }, { "name": "CROW_TAX_ENCRYPTION_KEY", - "description": "Passphrase for encrypting PII at rest (SSN, names). Set on first use.", + "description": "Passphrase that encrypts personal data at rest (SSN, names). Choose your own and keep a copy: without it saved returns can't be read. Changing it keeps the key it replaces in CROW_TAX_ENCRYPTION_KEY_PREVIOUS, and the tax server moves saved returns to the new key when it next starts. Restart it between two changes.", "required": true, + "secret": true, + "previous_key": "CROW_TAX_ENCRYPTION_KEY_PREVIOUS" + }, + { + "name": "CROW_TAX_ENCRYPTION_KEY_PREVIOUS", + "description": "Only after changing the passphrase: the old one, so returns saved with it can still be opened. Each return moves to the new passphrase when the tax server next starts or the return is saved; then this can be cleared.", + "required": false, "secret": true } ], @@ -6697,7 +6741,7 @@ { "id": "writefreely", "name": "WriteFreely", - "version": "1.0.2", + "version": "1.0.3", "description": "Lightweight, minimalist ActivityPub blogging platform. Single-binary, single-SQLite-file footprint — ideal for long-form writing federated to the fediverse. Complements GoToSocial (microblog) and Crow's own blog.", "type": "bundle", "author": "Crow", @@ -6750,7 +6794,7 @@ "env_vars": [ { "name": "WF_HOST", - "description": "Public domain for this WriteFreely instance (must be a subdomain; subpath mounts break ActivityPub).", + "description": "Public domain for this WriteFreely instance (must be a subdomain; subpath mounts break ActivityPub). Set it before the first start: the app fixes it then, and changing it later breaks federation (only a reinstall with its data deleted can change it).", "required": true }, { diff --git a/scripts/lib/bundle-contract.mjs b/scripts/lib/bundle-contract.mjs index 034765ce8..187cb0874 100644 --- a/scripts/lib/bundle-contract.mjs +++ b/scripts/lib/bundle-contract.mjs @@ -201,6 +201,16 @@ export function validateManifest(manifest, bundleDir, opts = {}) { if (v.propagate === true && v.store_as !== undefined) errors.push(`env_vars ${v.name}: propagate: true cannot be combined with store_as (the gateway would get a hash)`); } if (v.advanced !== undefined && typeof v.advanced !== "boolean") errors.push(`env_vars ${v.name}: advanced must be a boolean`); + if (v.previous_key !== undefined) { + const other = (manifest.env_vars || []).find((o) => o && o.name === v.previous_key); + if (typeof v.previous_key !== "string" || !other || other.name === v.name) errors.push(`env_vars ${v.name}: previous_key must name another declared env var`); + } + if (v.default_in_crow_home !== undefined) { + const rel = v.default_in_crow_home; + if (typeof rel !== "string" || rel === "" || rel.startsWith("/") || rel.includes("\\") || rel.includes("$") || rel.split("/").includes("..")) { + errors.push(`env_vars ${v.name}: default_in_crow_home must be a plain relative path inside CROW_HOME`); + } + } if (v.generatable === true) { if (v.secret !== true) errors.push(`env_vars ${v.name}: generatable needs secret: true`); if (v.generate !== undefined) errors.push(`env_vars ${v.name}: generatable is for typed fields; it cannot be combined with generate`); diff --git a/servers/gateway/bundle-env-placeholders.js b/servers/gateway/bundle-env-placeholders.js new file mode 100644 index 000000000..8b2679808 --- /dev/null +++ b/servers/gateway/bundle-env-placeholders.js @@ -0,0 +1,44 @@ +/** + * Placeholder values (queue item 30, fix C). A bundle's .env.example documents its keys + * with example values ("/path/to/media", "gts.example.com", "change-me"). Copied as the + * real .env they reached the app: a fake media mount Docker created as root, a fake + * domain, and an encryption key published in this public repo. And "Finish setup" + * counted them as filled. + * + * isPlaceholderValue() recognises them by shape, plus the SHA-256 of example values that + * were once published in this repo (so a live .env still holding one is flagged even + * though the example file no longer carries it). The values themselves are not kept here. + */ +import { createHash } from "node:crypto"; + +const PATTERNS = [ + /\/path\/to(?:\/|$)/i, + /\byour-?user\b/i, + /(?:^|[^a-z])your[-_]/i, + /(?:^|[.@/:])example\.(?:com|org|net)\b/i, + /\bchange[-_ ]?me\b/i, + /\breplace[-_ ]?me\b/i, + /^<[^>]*>$/, + /\bxxx+\b/i, + /\bplaceholder\b/i, +]; + +// SHA-256 of example secret values once published in bundles/*/.env.example. +const PUBLISHED = new Set([ + "113e3beef46e2a2ab77b0c3ea62ef37d823b82eab4f713a44af32bcdd2655365", // tax CROW_TAX_ENCRYPTION_KEY + "057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86", // browser VNC / nextcloud DB example + "d014d8b6286a673353a61a63d55ca5e449103aad4153d4b99796fe89fe93c7d3", // coturn TURN_SECRET example + "caced0266d7c16e33b0e1ffba522a0c614db64334761f8374f1ba46990dda696", // nextcloud DB root example +]); + +export function isPublishedExampleValue(v) { + if (typeof v !== "string" || v === "") return false; + return PUBLISHED.has(createHash("sha256").update(v).digest("hex")); +} + +export function isPlaceholderValue(v) { + if (typeof v !== "string") return false; + const s = v.trim(); + if (s === "") return false; + return PATTERNS.some((re) => re.test(s)) || isPublishedExampleValue(s); +} diff --git a/servers/gateway/bundle-env-secrets.js b/servers/gateway/bundle-env-secrets.js index 8179dee5e..6b5ef4ff2 100644 --- a/servers/gateway/bundle-env-secrets.js +++ b/servers/gateway/bundle-env-secrets.js @@ -273,7 +273,10 @@ export function gatewayGeneratedKeys(manifest) { */ export function retainGeneratedForReinstall(bundleId, manifest, { destDir, crowHome, composeText = null, includeFallbacks = true }) { try { - const keys = generatedEnvKeys(manifest); + // Plus the library paths a reinstall must not lose (env_vars[].default_in_crow_home): + // kept data (Jellyfin's database) still points at the user's real media folder. + const pathKeys = new Set((manifest?.env_vars || []).filter((v) => v && typeof v.name === "string" && v.default_in_crow_home).map((v) => v.name)); + const keys = [...generatedEnvKeys(manifest), ...pathKeys]; if (keys.length === 0 || !/^[a-z0-9][a-z0-9-]*$/.test(String(bundleId)) || String(bundleId).length > 64) return 0; const installedPath = join(destDir, ".env"); if (!existsSync(installedPath)) return 0; @@ -285,7 +288,9 @@ export function retainGeneratedForReinstall(bundleId, manifest, { destDir, crowH const retained = readEnvSafe(retainedPath); const add = {}; for (const k of keys) { - if (retained[k]) continue; + // A library path follows the latest install (the user may have pointed it elsewhere); + // a secret is never replaced (its kept database expects the old one). + if (retained[k] && !(pathKeys.has(k) && installed[k] && installed[k] !== retained[k])) continue; const v = installed[k] || legacy[k]; if (v) add[k] = v; } diff --git a/servers/gateway/bundles-config.js b/servers/gateway/bundles-config.js index e3ce8b982..8bc798e8e 100644 --- a/servers/gateway/bundles-config.js +++ b/servers/gateway/bundles-config.js @@ -12,6 +12,7 @@ * Imports node builtins ONLY — no circular-import risk with routes/bundles.js. */ +import { isPlaceholderValue } from "./bundle-env-placeholders.js"; import { parseEnvText } from "./bundle-env-codec.js"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; @@ -161,8 +162,8 @@ export function resolveEffectiveEnv(bundleId, manifest, { envOverride = null } = /** * Manifest-required env keys that are still EMPTY in the bundle's EFFECTIVE env. * - * Keys with a value (including .env.example defaults — DB passwords, secret keys) - * count as configured and are NEVER surfaced: those are consumed at first container + * Keys with a real value count as configured and are NEVER surfaced (an example + * placeholder does not count — isPlaceholderValue): those are consumed at first container * boot, and changing them afterwards breaks the app or strands its data. Corollary: * manifest `default`s are copied into mcp-addons.json at install, so an MCP bundle * whose required key has a placeholder default will never be reported. Same rule. @@ -180,5 +181,7 @@ export function needsConfigKeys(bundleId, envOverride = null) { if (required.length === 0) return []; const { managed, env } = resolveEffectiveEnv(bundleId, man, { envOverride }); if (!managed) return []; - return required.filter((k) => !isSet(env[k])); + // An example value copied from .env.example ("/path/to/media", "x.example.com", a key + // once published in the repo) is not configuration: list it, never rewrite the .env. + return required.filter((k) => !isSet(env[k]) || isPlaceholderValue(env[k])); } diff --git a/servers/gateway/dashboard/panels/extensions/client.js b/servers/gateway/dashboard/panels/extensions/client.js index 8e33e842d..5a597ff6f 100644 --- a/servers/gateway/dashboard/panels/extensions/client.js +++ b/servers/gateway/dashboard/panels/extensions/client.js @@ -694,12 +694,19 @@ export function extensionsClientJS(lang) { // Keychain / vault outcome (review S4): shown, and given time to be read. var kcMsgs = (res.data.keychain && res.data.keychain.messages) || []; if (kcMsgs.length) statusDiv.textContent += " " + kcMsgs.join(" "); + // Server warnings (e.g. a replaced previous encryption key): shown, in the + // warning colour, long enough to read. + var warnMsgs = res.data.warnings || []; + if (warnMsgs.length) { + statusDiv.style.color = "var(--crow-warning, #f0ad4e)"; + statusDiv.textContent += " " + warnMsgs.join(" "); + } setTimeout(function() { // Hand the WHOLE response to onSaved: needs_config is the server's // re-derived truth about what is still missing, and the only thing // allowed to decide whether the "Needs setup" badge may come off. if (typeof onSaved === "function") onSaved(res.data); - }, kcMsgs.length ? 4000 : 1200); + }, warnMsgs.length ? 9000 : (kcMsgs.length ? 4000 : 1200)); } else { statusDiv.style.color = "var(--crow-error, #e74c3c)"; statusDiv.textContent = (res.data && res.data.error) || '${tJs("extensions.configureFailed", lang)}'; diff --git a/servers/gateway/routes/bundles.js b/servers/gateway/routes/bundles.js index a58b2e906..940b3bd64 100644 --- a/servers/gateway/routes/bundles.js +++ b/servers/gateway/routes/bundles.js @@ -61,7 +61,8 @@ import { provisionNtfy, deprovisionNtfy, AUTOWIRE_KIND } from "../push/ntfy-prov import { checkComposeModel, loadComposeModel, usesHostNetwork } from "../compose-guard.js"; import { resolveDataDir } from "../../db.js"; import { readStoredNtfyConfig } from "../push/ntfy-config.js"; -import { planGeneratedEnv, stripGeneratedKeys, parseEnvText, writePrivateFile, gatewayExcludedKeys, gatewayGeneratedKeys, retainGeneratedForReinstall, envPatternViolation, breachedValueViolation } from "../bundle-env-secrets.js"; +import { isPlaceholderValue } from "../bundle-env-placeholders.js"; +import { planGeneratedEnv, retainedEnvPath, stripGeneratedKeys, parseEnvText, writePrivateFile, gatewayExcludedKeys, gatewayGeneratedKeys, retainGeneratedForReinstall, envPatternViolation, breachedValueViolation } from "../bundle-env-secrets.js"; /** * Seed an STT/TTS profile from a bundle manifest's {stt,tts}ProfileSeed into the @@ -1283,6 +1284,16 @@ export function removePanelEnabled(panelId, path = PANELS_CONFIG_PATH) { * install (either branch) and the version refresh write. envKeys values come only from an * install request; every truthy env_vars default rides along (unchanged install behaviour). */ +// `reqEnv` may carry generated values (runInstallJob passes them): only keys the manifest +// lists in server.envKeys reach the entry, so a generated secret reaches its own MCP child +// through this instance's mcp-addons.json (mode 600) and never the shared gateway .env. +/** Generated values an MCP child may get: never a stored hash (store_as), which is useless + * to the child and must not look like the secret. */ +function generatedPlainForMcp(manifest, generated) { + const hashed = new Set((manifest?.env_vars || []).filter((v) => v && v.store_as).map((v) => v.name)); + return Object.fromEntries(Object.entries(generated || {}).filter(([k]) => !hashed.has(k))); +} + export function mcpAddonEntryFor(manifest, reqEnv = null, { bundleDir = null } = {}) { const env = {}; for (const key of manifest?.server?.envKeys || []) if (reqEnv && reqEnv[key]) env[key] = reqEnv[key]; @@ -2041,7 +2052,7 @@ export function installBlockingEnvKeys(bundleId, manifest = getManifest(bundleId try { if (existsSync(composePath)) text = readFileSync(composePath, "utf8"); } catch { /* unreadable → only install_required can block */ } const hard = hardFailComposeKeys(text); return (manifest?.env_vars || []) - .filter((v) => v && typeof v.name === "string" && !v.generate && !nonBlankEnv(v.default) + .filter((v) => v && typeof v.name === "string" && !v.generate && !nonBlankEnv(v.default) && !crowHomeDefaultRel(v) && (v.install_required === true || (v.required && hard.has(v.name)))) .map((v) => v.name); } @@ -2312,7 +2323,7 @@ export async function validateInstall(bundleId, { envVars = {}, consentToken = n * * Every rung writes mode 600 — bundle .env files hold secrets. */ -export function writeInstallEnv(destDir, envVars, manifest, log = () => {}, { baseText = null } = {}) { +export function writeInstallEnv(destDir, envVars, manifest, log = () => {}, { baseText = null, baseIsExample = false, crowHome = null, retained = null } = {}) { const envPath = join(destDir, ".env"); const examplePath = join(destDir, ".env.example"); const usable = {}; @@ -2321,22 +2332,54 @@ export function writeInstallEnv(destDir, envVars, manifest, log = () => {}, { ba } const count = Object.keys(usable).length; const pathKeys = pathEnvKeys(manifest); + // Keys with env_vars[].default_in_crow_home that would install blank or as an example + // value get a folder inside this instance's CROW_HOME, created now (by the gateway user, + // so Docker never creates it as root). + // An existing .env (reinstall) is never "fixed" behind the user's back: there only a + // blank or missing key gets the default; the checklist flags a placeholder instead. + const homeDefaults = (base, { replacePlaceholders = true } = {}) => { + const out = {}; + if (!crowHome) return out; + const baseEnv = parseEnvText(base || ""); + for (const v of manifest?.env_vars || []) { + const rel = crowHomeDefaultRel(v); + if (!rel) continue; + const cur = usable[v.name] ?? baseEnv[v.name]; + if (cur !== undefined && cur !== "" && !(replacePlaceholders && isPlaceholderValue(cur))) continue; + // A reinstall over kept data: the path the earlier install used (retained at uninstall). + const kept = retained?.[v.name]; + if (kept && !isPlaceholderValue(kept)) { out[v.name] = kept; continue; } + const dir = join(crowHome, rel); + try { mkdirSync(dir, { recursive: true, mode: 0o755 }); } catch { continue; } + out[v.name] = dir; + } + if (Object.keys(out).length) log(`Set ${Object.keys(out).join(", ")} (the earlier install's path, else a folder inside Crow's home; change it in Configure)`); + return out; + }; if (typeof baseText === "string") { // Seeded from an existing .env / .env.example: line-preserving (C4) — the base's // own lines (comments, legacy values, ${VAR} references) stay byte-for-byte. - writePrivateFile(envPath, updateEnvText(baseText, usable, { pathKeys })); + writePrivateFile(envPath, updateEnvText(baseText, { ...homeDefaults(baseText, { replacePlaceholders: baseIsExample }), ...usable }, { pathKeys })); log(`Wrote ${count} env vars`); return; } if (count > 0) { - writePrivateFile(envPath, formatEnvLines(usable, { pathKeys })); + writePrivateFile(envPath, formatEnvLines({ ...homeDefaults(""), ...usable }, { pathKeys })); log(`Wrote ${count} env vars`); return; } if (existsSync(envPath)) { chmodSync(envPath, 0o600); return; } if (existsSync(examplePath)) { - writePrivateFile(envPath, readFileSync(examplePath, "utf8")); - log("Created .env from .env.example"); + // Example values are documentation, never live config: placeholder lines become + // comments (the checklist then lists the key), the rest is kept. + const text = scrubPlaceholderLines(readFileSync(examplePath, "utf8")); + writePrivateFile(envPath, updateEnvText(text, homeDefaults(text), { pathKeys })); + log("Created .env from .env.example (example values left out)"); + return; + } + const defaults = homeDefaults(""); + if (Object.keys(defaults).length) { + writePrivateFile(envPath, formatEnvLines(defaults, { pathKeys })); return; } if ((manifest?.env_vars || []).length > 0) { @@ -2345,6 +2388,25 @@ export function writeInstallEnv(destDir, envVars, manifest, log = () => {}, { ba } } +/** env_vars[].default_in_crow_home as a safe relative path, or null. */ +export function crowHomeDefaultRel(v) { + const rel = v?.default_in_crow_home; + if (typeof rel !== "string" || rel === "" || isAbsolute(rel) || rel.includes("\\") || rel.includes("$")) return null; + const norm = relativePath(".", rel); + if (!norm || norm.startsWith("..") || isAbsolute(norm)) return null; + return norm; +} + +/** .env text with every KEY= line turned into a comment naming only the key. */ +export function scrubPlaceholderLines(text) { + return String(text || "").split("\n").map((line) => { + const m = line.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); + if (!m) return line; + const val = parseEnvText(`${m[2]}=${m[3]}`)[m[2]]; + return isPlaceholderValue(val) ? `# ${m[2]}= (example value left out: set it in Extensions)` : line; + }).join("\n"); +} + export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, consentVerified, manifest, keychain = null }) { let needsRestart = false; // Set when `docker compose up` fails. The install does NOT stop there: the @@ -2452,6 +2514,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, const writeEnv = { ...(reqEnv || {}), ...generated }; let installEnv = writeEnv; let baseText = null; + let baseIsExample = false; if (Object.keys(generated).length > 0) { // Writing values bypasses the .env / .env.example rungs, so seed from them: // existing .env, else .env.example — LINE-PRESERVING (C4): the base's own lines are @@ -2460,7 +2523,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, const exP = join(destDir, ".env.example"); try { if (existsSync(envP)) baseText = readFileSync(envP, "utf8"); - else if (existsSync(exP)) baseText = readFileSync(exP, "utf8"); + else if (existsSync(exP)) { baseText = scrubPlaceholderLines(readFileSync(exP, "utf8")); baseIsExample = true; } } catch { /* unreadable base: fall back to provided values only */ } if (baseText !== null) installEnv = { ...parseEnvText(baseText), ...writeEnv }; } @@ -2475,7 +2538,9 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, return { ok: false, reason: "could not save the generated password to Crow keychain; nothing was written — retry the install" }; } plan.persist(); - writeInstallEnv(destDir, writeEnv, manifest, (msg) => appendLog(job, msg), { baseText }); + let retainedForPaths = null; + try { retainedForPaths = parseEnvText(readFileSync(retainedEnvPath(CROW_HOME, bundleId), "utf8")); } catch { /* none kept */ } + writeInstallEnv(destDir, writeEnv, manifest, (msg) => appendLog(job, msg), { baseText, baseIsExample, crowHome: CROW_HOME, retained: retainedForPaths }); if (Object.keys(generated).length > 0) { appendLog(job, `Generated ${Object.keys(generated).length} internal secret(s) — stored at mode 600, never shown`); } @@ -2590,7 +2655,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, // locked". The MCP child instead inherits the SPAWNING gateway's // CROW_DB_PATH via process.env at launch (its own default otherwise), // so each gateway's children always use that gateway's DB. - mcpAddons[bundleId] = mcpAddonEntryFor(manifest, reqEnv, { bundleDir: destDir }); + mcpAddons[bundleId] = mcpAddonEntryFor(manifest, { ...(reqEnv || {}), ...generatedPlainForMcp(manifest, generated) }, { bundleDir: destDir }); writeJsonSafe(MCP_ADDONS_PATH, mcpAddons); appendLog(job, `Registered MCP server '${bundleId}'`); needsRestart = true; @@ -2627,7 +2692,7 @@ export async function runInstallJob(bundleId, envVars, { job, installedSnapshot, // MCP server — register in mcp-addons.json if (manifest?.server) { const mcpAddons = readJsonSafe(MCP_ADDONS_PATH, {}); - mcpAddons[bundleId] = mcpAddonEntryFor(manifest, reqEnv, { bundleDir: destDir }); + mcpAddons[bundleId] = mcpAddonEntryFor(manifest, { ...(reqEnv || {}), ...generatedPlainForMcp(manifest, generated) }, { bundleDir: destDir }); writeJsonSafe(MCP_ADDONS_PATH, mcpAddons); appendLog(job, `Registered MCP server '${bundleId}'`); } @@ -3459,7 +3524,7 @@ export default function bundlesRouter() { let composeTextForRetain = null; try { const cp = join(bundleDir, "docker-compose.yml"); if (existsSync(cp)) composeTextForRetain = readFileSync(cp, "utf8"); } catch {} const keptSecrets = retainGeneratedForReinstall(bundle_id, manifest, { destDir: bundleDir, crowHome: CROW_HOME, composeText: composeTextForRetain, includeFallbacks: !volumesRemoved }); - if (keptSecrets) appendLog(job, `Kept ${keptSecrets} generated secret(s) for a later reinstall (mode 600)`); + if (keptSecrets) appendLog(job, `Kept ${keptSecrets} generated secret(s) / library path(s) for a later reinstall (mode 600)`); rmSync(bundleDir, { recursive: true, force: true }); appendLog(job, "Bundle files removed"); @@ -3678,6 +3743,30 @@ export default function bundlesRouter() { const oldEnvText = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""; const existing = parseEnvText(oldEnvText); + // env_vars[].previous_key (tax's encryption key): a key change that does not fill the + // previous-key slot itself keeps the outgoing key there, so returns saved with it stay + // readable. The outgoing key is the one the MCP child will start with (its entry, the + // gateway env, this .env). The slot always takes the key just replaced: the tax + // server moves every return to the current key when it starts, so an older key in + // the slot only still matters if it did not start since the last change — the + // warning (key names only) says so. + const keyWarnings = []; + { + const entryEnv = readJsonSafe(MCP_ADDONS_PATH, {})?.[bundle_id]?.env || {}; + for (const v of getInstalledFirstManifest(bundle_id)?.env_vars || []) { + const prev = v && typeof v.previous_key === "string" ? v.previous_key : null; + if (!prev || env_vars[v.name] === undefined || env_vars[v.name] === "" || env_vars[prev] !== undefined) continue; + const next = String(env_vars[v.name]); + const old = entryEnv[v.name] || process.env[v.name] || existing[v.name] || ""; + if (!old || old === next) continue; + const kept = entryEnv[prev] || process.env[prev] || existing[prev] || ""; + env_vars[prev] = old; + if (kept && kept !== old && kept !== next) { + keyWarnings.push(`${prev} now holds the key you just replaced (it held an older one). Data still on that older key, only if the extension did not restart since the last change, opens only if you enter it in ${prev}.`); + } + } + } + // Keys whose value this save actually changes (names only, never values). const changedKeys = Object.keys(env_vars).filter((k) => { const next = env_vars[k]; @@ -3747,9 +3836,11 @@ export default function bundlesRouter() { } else if (appliesOnNextStart) { message += `. The bundle is not running; ${bundleRestartKeys.join(", ")} will apply on its next start`; } + if (keyWarnings.length) message += `. ${keyWarnings.join(" ")}`; res.json({ ok: true, message, + ...(keyWarnings.length ? { warnings: keyWarnings } : {}), needs_restart: needsRestart, needs_bundle_restart: needsBundleRestart, applies_on_next_start: appliesOnNextStart, diff --git a/tests/bundle-env-placeholders.test.js b/tests/bundle-env-placeholders.test.js new file mode 100644 index 000000000..585e3e581 --- /dev/null +++ b/tests/bundle-env-placeholders.test.js @@ -0,0 +1,271 @@ +/** + * No placeholder value reaches an install (queue item 30, fix C). Example values from + * .env.example are left out of the real .env; path keys with default_in_crow_home get a + * folder inside CROW_HOME; "Finish setup" lists a key whose value is a placeholder; the tax + * bundle never encrypts with an example key. Never prints a secret value. + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, existsSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +process.env.CROW_HOME = mkdtempSync(join(tmpdir(), "crow-ph-home-")); +process.env.CROW_DATA_DIR = mkdtempSync(join(tmpdir(), "crow-ph-data-")); +process.env.CROW_AUTO_UPDATE = "0"; +process.env.CROW_DISABLE_HEALTH_MONITOR = "1"; +process.env.CROW_DISABLE_INSTANCE_SYNC = "1"; +process.env.CROW_DISABLE_NOSTR = "1"; +execFileSync(process.execPath, ["scripts/init-db.js"], { env: process.env, stdio: "pipe" }); + +const REPO = fileURLToPath(new URL("..", import.meta.url)); +const CROW_HOME = process.env.CROW_HOME; +const P = await import("../servers/gateway/bundle-env-placeholders.js"); +const B = await import("../servers/gateway/routes/bundles.js"); +const C = await import("../servers/gateway/bundles-config.js"); +const { parseEnvText } = await import("../servers/gateway/bundle-env-codec.js"); +const { validateManifest } = await import("../scripts/lib/bundle-contract.mjs"); +const { trackedBundleSet } = await import("../scripts/build-registry.mjs"); + +const FIXTURES = mkdtempSync(join(tmpdir(), "crow-ph-app-")); +B._setAppBundlesForTest(FIXTURES); +B._setComposeRunnerForTest(async () => ({ stdout: "", stderr: "" })); +B._setDockerRunnerForTest(async () => ({ stdout: "", stderr: "" })); +after(() => { + B._setComposeRunnerForTest(null); + B._setDockerRunnerForTest(null); + for (const d of [CROW_HOME, process.env.CROW_DATA_DIR, FIXTURES]) rmSync(d, { recursive: true, force: true }); +}); +const manifestOf = (id) => JSON.parse(readFileSync(join(REPO, "bundles", id, "manifest.json"), "utf8")); +const scratch = () => mkdtempSync(join(tmpdir(), "crow-ph-")); + +test("detector: example shapes are placeholders; real defaults are not", () => { + for (const v of ["/path/to/media", "/home/youruser/.crow", "your-secure-password", "gts.example.com", "@admin:example.com", + "change-me", "CHANGEME", "replace_me", "", "xxxx", "placeholder"]) { + assert.equal(P.isPlaceholderValue(v), true, v); + } + for (const v of ["", "http://localhost:8085", "crow.local", "admin", "true", "/srv/media", "~/.crow/data/rookery/workspaces", + "http://kodi.local:8080", "3005", "local-model", "my-yourself", "example"]) { + assert.equal(P.isPlaceholderValue(v), false, v); + } +}); + +test("scrub: placeholder lines from .env.example become comments naming only the key", () => { + const out = B.scrubPlaceholderLines("# comment\nA=/path/to/media\nB=http://localhost:1\nexport C=change-me\nD=\n"); + const env = parseEnvText(out); + assert.equal(env.A, undefined); + assert.equal(env.C, undefined); + assert.equal(env.B, "http://localhost:1"); + assert.match(out, /^# A= \(example value left out/m); + assert.ok(!out.includes("/path/to/media") && !out.includes("change-me")); +}); + +test("writeInstallEnv (no values): example values are left out; a crow-home default is created and used", () => { + const dest = scratch(); + writeFileSync(join(dest, ".env.example"), "MEDIA=/path/to/media\nPORT=8096\nDOMAIN=x.example.com\n"); + const manifest = { env_vars: [{ name: "MEDIA", required: true, default_in_crow_home: "media/test" }, { name: "PORT", default: "8096" }, { name: "DOMAIN", required: true }] }; + B.writeInstallEnv(dest, {}, manifest, () => {}, { crowHome: CROW_HOME }); + const env = parseEnvText(readFileSync(join(dest, ".env"), "utf8")); + assert.equal(env.MEDIA, join(CROW_HOME, "media/test")); + assert.ok(statSync(join(CROW_HOME, "media/test")).isDirectory()); + assert.equal(env.PORT, "8096"); + assert.equal(env.DOMAIN, undefined, "left for Finish setup"); +}); + +test("writeInstallEnv never rewrites a placeholder already in an existing .env (reinstall): only blank keys get the default", () => { + const dest = scratch(); + const manifest = { env_vars: [{ name: "MEDIA", required: true, default_in_crow_home: "media/r" }, { name: "MUSIC", default_in_crow_home: "media/m" }] }; + const base = "MEDIA=/path/to/media\nMUSIC=\n"; + writeFileSync(join(dest, ".env"), base); + B.writeInstallEnv(dest, { X: "1" }, manifest, () => {}, { baseText: base, baseIsExample: false, crowHome: CROW_HOME }); + const env = parseEnvText(readFileSync(join(dest, ".env"), "utf8")); + assert.equal(env.MEDIA, "/path/to/media", "the live value is left for the checklist to flag"); + assert.equal(env.MUSIC, join(CROW_HOME, "media/m")); +}); + +test("a typed value wins over the crow-home default", () => { + const dest = scratch(); + B.writeInstallEnv(dest, { MEDIA: "/srv/films" }, { env_vars: [{ name: "MEDIA", default_in_crow_home: "media/x" }] }, () => {}, { crowHome: CROW_HOME }); + assert.equal(parseEnvText(readFileSync(join(dest, ".env"), "utf8")).MEDIA, "/srv/films"); +}); + +test("contract: default_in_crow_home must be a plain relative path inside CROW_HOME", () => { + for (const bad of ["/abs", "../up", "a/../../b", "$HOME/x", "a\\b", ""]) { + assert.equal(B.crowHomeDefaultRel({ default_in_crow_home: bad }), null, bad); + } + assert.equal(B.crowHomeDefaultRel({ default_in_crow_home: "media/jellyfin" }), "media/jellyfin"); + for (const id of ["jellyfin", "navidrome"]) { + const m = manifestOf(id); + assert.ok((m.env_vars || []).some((v) => B.crowHomeDefaultRel(v)), `${id} declares a crow-home media default`); + assert.deepEqual(validateManifest(m, join(REPO, "bundles", id)).errors || [], [], `${id} contract`); + } + const r = validateManifest({ ...manifestOf("jellyfin"), env_vars: [{ name: "X", default_in_crow_home: "../etc" }] }, join(REPO, "bundles", "jellyfin")); + assert.ok((r.errors || []).some((e) => /default_in_crow_home/.test(e)), "the contract refuses a path that climbs out"); +}); + +test("media bundles no longer block a blank install: their library defaults inside CROW_HOME", () => { + B._setAppBundlesForTest(join(REPO, "bundles")); + try { + for (const id of ["jellyfin", "navidrome"]) assert.deepEqual(B.installBlockingEnvKeys(id), [], id); + } finally { B._setAppBundlesForTest(FIXTURES); } +}); + +test("collection-style install of jellyfin (empty env): the media path is a CROW_HOME folder, never the example path", async () => { + const id = "jellyfin"; + const dir = join(FIXTURES, id); + mkdirSync(dir, { recursive: true }); + for (const f of [".env.example", "docker-compose.yml"]) copyFileSync(join(REPO, "bundles", id, f), join(dir, f)); + // No ports/webUI: the installer would otherwise try `sudo -n ufw` / `tailscale serve` on this host. + const manifest = { ...manifestOf(id), ports: [], webUI: null }; + writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); + const job = B._createJobForTest(id, "install"); + await B.runInstallJob(id, {}, { job, installedSnapshot: [], consentVerified: false, manifest }); + const env = parseEnvText(readFileSync(join(CROW_HOME, "bundles", id, ".env"), "utf8")); + assert.ok(env.JELLYFIN_MEDIA_PATH && env.JELLYFIN_MEDIA_PATH.startsWith(CROW_HOME), "a folder inside CROW_HOME"); + assert.ok(!Object.values(env).some((v) => P.isPlaceholderValue(v)), "no placeholder value in the installed .env"); +}); + +test("Finish setup lists a required key whose value is a placeholder", () => { + const man = { env_vars: [{ name: "DOMAIN", required: true }, { name: "TOKEN", required: true }, { name: "OK", required: true }] }; + const id = "fx-ph"; + mkdirSync(join(CROW_HOME, "bundles", id), { recursive: true }); + writeFileSync(join(CROW_HOME, "bundles", id, "manifest.json"), JSON.stringify({ id, ...man })); + assert.deepEqual(C.needsConfigKeys(id, { DOMAIN: "gts.example.com", TOKEN: "", OK: "real" }).sort(), ["DOMAIN", "TOKEN"]); +}); + +test("no tracked .env.example ships a value for a secret key (unless it is the manifest default) or a placeholder for a required key", () => { + const LATER = new Set(); + const bad = []; + for (const id of [...trackedBundleSet()].sort()) { + if (LATER.has(id)) continue; + const ex = join(REPO, "bundles", id, ".env.example"); + if (!existsSync(ex)) continue; + const m = manifestOf(id); + const specs = new Map((m.env_vars || []).map((v) => [v.name, v])); + for (const [k, v] of Object.entries(parseEnvText(readFileSync(ex, "utf8")))) { + if (!v) continue; + const spec = specs.get(k) || {}; + // Generated keys never take the example value (the installer mints over it), and + // CROW_HOME is set by Crow for every compose run. + if (spec.generate || k === "CROW_HOME") continue; + const secretish = spec.secret === true || /(PASSWORD|SECRET|_KEY|TOKEN)$/.test(k); + if (secretish && String(spec.default ?? "") !== v) bad.push(`${id}: ${k} carries an example secret value`); + else if (P.isPlaceholderValue(v)) bad.push(`${id}: ${k} carries a placeholder value`); + } + } + assert.deepEqual(bad, [], bad.join("\n")); +}); + +test("tax: refuses to encrypt with the published example key (matched by hash); a real passphrase of any shape works", async () => { + const { encrypt, decrypt, isExampleKey } = await import("../bundles/tax/server/crypto.js"); + // The published value is gone from the repo; its hash is what the guard knows. + const { createHash } = await import("node:crypto"); + assert.equal(isExampleKey("not-it"), false); + assert.equal(createHash("sha256").update("x").digest("hex").length, 64); + for (const real of ["a-real-passphrase-9f8e7d", "your-own-words-are-fine", "change me later maybe"]) { + assert.equal(decrypt(encrypt("ssn", real), real), "ssn", real); + } +}); + +test("tax: after a passphrase change, data saved with the old one opens through CROW_TAX_ENCRYPTION_KEY_PREVIOUS", async () => { + const { encrypt, decryptWithFallback } = await import("../bundles/tax/server/crypto.js"); + const old = encrypt("old-ssn", "old-pass-1"); + assert.equal(decryptWithFallback(old, ["new-pass-2", "old-pass-1"]), "old-ssn"); + assert.throws(() => decryptWithFallback(old, ["new-pass-2", undefined])); +}); + +test("tax: decrypt keeps working for data an install already encrypted with an example key", async () => { + const { decrypt } = await import("../bundles/tax/server/crypto.js"); + const { createCipheriv, randomBytes, pbkdf2Sync } = await import("node:crypto"); + const key = "change-me-old-install"; + const salt = randomBytes(16), iv = randomBytes(12); + const c = createCipheriv("aes-256-gcm", pbkdf2Sync(key, salt, 100000, 32, "sha256"), iv); + const ct = Buffer.concat([c.update("old-ssn", "utf8"), c.final()]); + const packed = Buffer.concat([salt, iv, c.getAuthTag(), ct]).toString("base64"); + assert.equal(decrypt(packed, key), "old-ssn"); +}); + +test("reinstall over kept data reuses the library path the earlier install used, not an empty CROW_HOME folder", () => { + const S = { retain: null }; + const dest = scratch(); + const manifest = { env_vars: [{ name: "MEDIA", required: true, default_in_crow_home: "media/kept" }] }; + B.writeInstallEnv(dest, {}, manifest, () => {}, { crowHome: CROW_HOME, retained: { MEDIA: "/srv/films" } }); + assert.equal(parseEnvText(readFileSync(join(dest, ".env"), "utf8")).MEDIA, "/srv/films"); + assert.equal(S.retain, null); +}); + +test("uninstall keeps library paths for a reinstall (retained copy)", async () => { + const sec = await import("../servers/gateway/bundle-env-secrets.js"); + const dest = scratch(); + const home = scratch(); + writeFileSync(join(dest, ".env"), "MEDIA=/srv/films\n"); + const n = sec.retainGeneratedForReinstall("fx-lib", { env_vars: [{ name: "MEDIA", default_in_crow_home: "media/x" }] }, { destDir: dest, crowHome: home }); + assert.equal(n, 1); + assert.equal(parseEnvText(readFileSync(sec.retainedEnvPath(home, "fx-lib"), "utf8")).MEDIA, "/srv/films"); +}); + +test("tax: at start, returns only the previous key opens are re-encrypted to the current key", async () => { + const { createDbClient } = await import("../servers/db.js"); + const { initTaxTables } = await import("../bundles/tax/server/init-tables.js"); + const { reencryptToCurrentKey } = await import("../bundles/tax/server/server.js"); + const { encrypt, decrypt } = await import("../bundles/tax/server/crypto.js"); + const db = createDbClient(join(scratch(), "tax.db")); + try { + await initTaxTables(db); + const ins = (id, data) => db.execute({ sql: "INSERT INTO tax_returns (id, tax_year, filing_status, data) VALUES (?, 2025, 'single', ?)", args: [id, data] }); + await ins("a", encrypt("on-old", "k1-old")); + await ins("b", encrypt("on-new", "k2-new")); + await ins("c", encrypt("on-other", "k0-lost")); + assert.equal(await reencryptToCurrentKey(db, "k2-new", "k1-old"), 1); + const rows = Object.fromEntries((await db.execute("SELECT id, data FROM tax_returns")).rows.map((r) => [r.id, r.data])); + assert.equal(decrypt(rows.a, "k2-new"), "on-old"); + assert.equal(decrypt(rows.b, "k2-new"), "on-new"); + assert.throws(() => decrypt(rows.c, "k2-new"), undefined, "a row neither key opens is left alone"); + assert.equal(await reencryptToCurrentKey(db, "k2-new", "k1-old"), 0, "idempotent"); + // A save that lands between the read and the write is never overwritten. + await ins("d", encrypt("old-version", "k1-old")); + const realExecute = db.execute.bind(db); + let raced = false; + db.execute = async (q) => { + if (!raced && typeof q === "object" && /^UPDATE/.test(q.sql)) { + raced = true; + await realExecute({ sql: "UPDATE tax_returns SET data = ? WHERE id = 'd'", args: [encrypt("user-edit", "k2-new")] }); + } + return realExecute(q); + }; + await reencryptToCurrentKey(db, "k2-new", "k1-old"); + db.execute = realExecute; + const d = (await db.execute("SELECT data FROM tax_returns WHERE id = 'd'")).rows[0].data; + assert.equal(decrypt(d, "k2-new"), "user-edit", "the newer save wins"); + } finally { db.close?.(); } +}); + +test("Configure: a tax key change always keeps the key just replaced in _PREVIOUS (and warns when an older one is dropped)", async () => { + const express = (await import("express")).default; + const id = "tax"; + const dir = join(CROW_HOME, "bundles", id); + mkdirSync(dir, { recursive: true }); + copyFileSync(join(REPO, "bundles", id, "manifest.json"), join(dir, "manifest.json")); + writeFileSync(join(dir, ".env"), "CROW_TAX_ENCRYPTION_KEY=old-pass-1\n"); + const app = express(); app.use(express.json()); app.use(B.default()); + const server = app.listen(0, "127.0.0.1"); await new Promise((r) => server.once("listening", r)); + try { + const post = (env) => fetch(`http://127.0.0.1:${server.address().port}/bundles/api/env`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ bundle_id: id, env_vars: env }) }).then(async (r) => ({ status: r.status, body: await r.json() })); + assert.equal((await post({ CROW_TAX_ENCRYPTION_KEY: "new-pass-2" })).status, 200); + let env = parseEnvText(readFileSync(join(dir, ".env"), "utf8")); + assert.equal(env.CROW_TAX_ENCRYPTION_KEY, "new-pass-2"); + assert.equal(env.CROW_TAX_ENCRYPTION_KEY_PREVIOUS, "old-pass-1"); + // (The tax server moved every return to new-pass-2 when it started.) A second change + // keeps the key just replaced, so nothing on the current key is ever stranded. + const r = await post({ CROW_TAX_ENCRYPTION_KEY: "newer-pass-3" }); + assert.equal(r.status, 200); + assert.ok((r.body.warnings || []).some((w) => /now holds the key you just replaced/.test(w)), "warns, naming keys only"); + assert.ok(!JSON.stringify(r.body).includes("old-pass-1") && !JSON.stringify(r.body).includes("new-pass-2")); + env = parseEnvText(readFileSync(join(dir, ".env"), "utf8")); + assert.equal(env.CROW_TAX_ENCRYPTION_KEY_PREVIOUS, "new-pass-2", "the key just replaced is kept"); + assert.equal((await post({ CROW_TAX_ENCRYPTION_KEY: "k4", CROW_TAX_ENCRYPTION_KEY_PREVIOUS: "newer-pass-3" })).status, 200, "a typed previous key wins"); + assert.equal(parseEnvText(readFileSync(join(dir, ".env"), "utf8")).CROW_TAX_ENCRYPTION_KEY_PREVIOUS, "newer-pass-3"); + } finally { server.close(); } +}); diff --git a/tests/bundle-form-friction.test.js b/tests/bundle-form-friction.test.js index 54b747334..2ab3258ae 100644 --- a/tests/bundle-form-friction.test.js +++ b/tests/bundle-form-friction.test.js @@ -145,10 +145,13 @@ test("every generated key of a docker bundle is consumed by its compose file", ( const keys = S.generatedEnvKeys(m); if (keys.length === 0) continue; const text = composeText(dir); - assert.ok(text, `${dir}: generated keys only make sense for a compose bundle (the MCP env never receives them)`); + assert.ok(text, `${dir}: generated keys only make sense for a compose bundle`); const consumed = B.composeConsumedKeys(text); - // Explicit interpolation only: an env_file .env would make `consumed.all` pass vacuously. - for (const k of keys) assert.ok(consumed.keys.has(k), `${dir}: ${k} is generated but its compose never interpolates it`); + // Consumed = interpolated by the compose file, or listed in server.envKeys (the + // installer puts those into this instance's MCP entry). Explicit interpolation only: + // an env_file .env would make `consumed.all` pass vacuously. + const mcpKeys = new Set(m.server?.envKeys || []); + for (const k of keys) assert.ok(consumed.keys.has(k) || mcpKeys.has(k), `${dir}: ${k} is generated but neither its compose nor its MCP server reads it`); } }); diff --git a/tests/bundle-generated-secrets.test.js b/tests/bundle-generated-secrets.test.js new file mode 100644 index 000000000..f19c5c97a --- /dev/null +++ b/tests/bundle-generated-secrets.test.js @@ -0,0 +1,309 @@ +/** + * Required passwords/keys Crow can choose itself are GENERATED at install and saved to + * the Crow keychain (queue item 30, fix B), the same way browser/minio/vaultwarden do it: + * env_vars[].generate + keychain (+ propagate where an MCP child reads the value from + * process.env). A collection install (empty env, no keychain request) must still end up + * with a value and a keychain entry. No test here prints a generated value. + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, cpSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +process.env.CROW_HOME = mkdtempSync(join(tmpdir(), "crow-gensec-home-")); +process.env.CROW_DATA_DIR = mkdtempSync(join(tmpdir(), "crow-gensec-data-")); +process.env.CROW_AUTO_UPDATE = "0"; +process.env.CROW_DISABLE_HEALTH_MONITOR = "1"; +process.env.CROW_DISABLE_INSTANCE_SYNC = "1"; +process.env.CROW_DISABLE_NOSTR = "1"; +execFileSync(process.execPath, ["scripts/init-db.js"], { env: process.env, stdio: "pipe" }); + +const REPO = fileURLToPath(new URL("..", import.meta.url)); +const CROW_HOME = process.env.CROW_HOME; +const B = await import("../servers/gateway/routes/bundles.js"); +const K = await import("../servers/gateway/keychain/store.js"); +const S = await import("../servers/gateway/bundle-env-secrets.js"); +const { parseEnvText } = await import("../servers/gateway/bundle-env-codec.js"); +const { createDbClient } = await import("../servers/db.js"); +const { loadKeychainKey } = await import("../servers/gateway/keychain/key.js"); +const { validateManifest } = await import("../scripts/lib/bundle-contract.mjs"); + +const FIXTURES = mkdtempSync(join(tmpdir(), "crow-gensec-app-")); +const GW_ENV = join(mkdtempSync(join(tmpdir(), "crow-gensec-gw-")), ".env"); +writeFileSync(GW_ENV, "# gateway env (test)\n"); +B._setAppBundlesForTest(FIXTURES); +B._setAppEnvPathForTest(GW_ENV); +B._setComposeRunnerForTest(async () => ({ stdout: "", stderr: "" })); +B._setDockerRunnerForTest(async () => ({ stdout: "", stderr: "" })); +const hookRuns = []; +B._setHookRunnerForTest(async (...args) => { hookRuns.push(args); return { stdout: "", stderr: "" }; }); +after(() => { + B._setAppEnvPathForTest(null); + B._setComposeRunnerForTest(null); + B._setDockerRunnerForTest(null); + B._setHookRunnerForTest(null); + for (const d of [CROW_HOME, process.env.CROW_DATA_DIR, FIXTURES]) rmSync(d, { recursive: true, force: true }); +}); + +const manifestOf = (id) => JSON.parse(readFileSync(join(REPO, "bundles", id, "manifest.json"), "utf8")); +const specOf = (id, name) => (manifestOf(id).env_vars || []).find((v) => v.name === name); + +// The keys this fix generates, and how each one reaches its consumer. +// campaigns/tax encryption keys are NOT here: generating them waits for the recovery-key +// decision (see tests/bundle-form-friction.test.js EXEMPT). +const EXPECT = [ + { id: "miniflux", key: "MINIFLUX_ADMIN_PASSWORD", propagate: false, username: "${MINIFLUX_ADMIN_USER}" }, + { id: "maker-lab-advanced", key: "MLA_ADMIN_PASSWORD", propagate: false, username: "${MLA_ADMIN_USER}" }, + { id: "actual-budget", key: "ACTUAL_SERVER_PASSWORD", propagate: false }, + { id: "lemmy", key: "LEMMY_ADMIN_PASSWORD", propagate: false, username: "admin_pending" }, +]; + +test("each key is generated, keychain-saved, and propagated only where an MCP child reads it", () => { + for (const e of EXPECT) { + const v = specOf(e.id, e.key); + assert.ok(v, `${e.id}: ${e.key} is declared`); + assert.equal(v.generate, "secret", `${e.id}.${e.key} generate`); + assert.equal(v.keychain, true, `${e.id}.${e.key} keychain`); + assert.equal(v.secret, true, `${e.id}.${e.key} secret`); + assert.equal(v.propagate === true, e.propagate, `${e.id}.${e.key} propagate`); + assert.ok(typeof v.keychain_label === "string" && v.keychain_label.length > 0, `${e.id}.${e.key} keychain_label`); + if (e.username) assert.equal(v.keychain_username, e.username, `${e.id}.${e.key} keychain_username`); + const r = validateManifest(manifestOf(e.id), join(REPO, "bundles", e.id)); + assert.deepEqual(r.errors || [], [], `${e.id} manifest contract`); + } +}); + +test("every username a keychain entry names has a default, so the entry is never left without one", () => { + assert.equal(specOf("maker-lab-advanced", "MLA_ADMIN_USER").default, "admin"); + assert.equal(specOf("miniflux", "MINIFLUX_ADMIN_USER").default, "admin"); +}); + +test("no generated key here goes to the shared gateway .env (co-hosted instances share it)", () => { + for (const e of EXPECT) assert.equal(S.gatewayGeneratedKeys(manifestOf(e.id)).includes(e.key), false, `${e.id}.${e.key}`); +}); + +/** Copy just the manifest + .env.example (no package.json: no npm in a unit test). */ +function stage(id) { + const dir = join(FIXTURES, id); + mkdirSync(dir, { recursive: true }); + for (const f of [".env.example", "docker-compose.yml"]) { + if (existsSync(join(REPO, "bundles", id, f))) copyFileSync(join(REPO, "bundles", id, f), join(dir, f)); + } + if (existsSync(join(REPO, "bundles", id, "scripts"))) cpSync(join(REPO, "bundles", id, "scripts"), join(dir, "scripts"), { recursive: true }); + // No ports/webUI: the installer would otherwise try `sudo -n ufw` / `tailscale serve` on this host. + const manifest = { ...manifestOf(id), ports: [], webUI: null }; + writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); + return manifest; +} + +async function entriesFor(id) { + const d = createDbClient(); + try { return (await K.listEntries(d, { keyId: loadKeychainKey().id })).filter((e) => e.bundle_id === id); } + finally { d.close(); } +} + +for (const e of EXPECT.filter((x) => ["miniflux", "actual-budget"].includes(x.id))) { + test(`collection-style install of ${e.id} (empty env, no keychain request): value minted, keychain entry made, .env holds it`, async () => { + const manifest = stage(e.id); + const job = B._createJobForTest(e.id, "install"); + const out = await B.runInstallJob(e.id, {}, { job, installedSnapshot: [], consentVerified: false, manifest, keychain: null }); + assert.equal(out.ok, true, out.reason); + const env = parseEnvText(readFileSync(join(CROW_HOME, "bundles", e.id, ".env"), "utf8")); + assert.match(env[e.key] || "", /^[A-Za-z0-9_-]{43}$/, `${e.key} is a freshly minted secret, not a placeholder`); + const rows = await entriesFor(e.id); + assert.equal(rows.length, 1, "one keychain entry"); + assert.equal(rows[0].env_key, e.key); + assert.equal(rows[0].first_view_pending, true); + const d = createDbClient(); + try { assert.equal(await K.openEntrySecret(d, loadKeychainKey(), rows[0].id), env[e.key], "keychain holds the same value"); } + finally { d.close(); } + const gw = parseEnvText(readFileSync(GW_ENV, "utf8")); + assert.equal(gw[e.key], undefined, `the shared gateway env never gets ${e.key}`); + const addons = JSON.parse(readFileSync(join(CROW_HOME, "mcp-addons.json"), "utf8")); + const listed = (manifest.server?.envKeys || []).includes(e.key); + assert.equal(addons[e.id]?.env?.[e.key] === env[e.key], listed, `${e.key} reaches this instance's MCP entry only when server.envKeys lists it`); + if (e.id === "miniflux") assert.equal(rows[0].username, "admin"); + const log = job.log.join("\n"); + assert.ok(!log.includes(env[e.key]), "the value never reaches the job log"); + }); +} + +test("reinstall keeps the kept value (never regenerated)", async () => { + const id = "miniflux"; + const before = parseEnvText(readFileSync(join(CROW_HOME, "bundles", id, ".env"), "utf8")).MINIFLUX_ADMIN_PASSWORD; + assert.ok(before); + rmSync(join(CROW_HOME, "bundles", id), { recursive: true, force: true }); + const manifest = stage(id); + const job = B._createJobForTest(id, "install"); + const out = await B.runInstallJob(id, {}, { job, installedSnapshot: [], consentVerified: false, manifest, keychain: null }); + assert.equal(out.ok, true, out.reason); + const afterVal = parseEnvText(readFileSync(join(CROW_HOME, "bundles", id, ".env"), "utf8")).MINIFLUX_ADMIN_PASSWORD; + assert.equal(afterVal, before); +}); + +test("lemmy: no literal credential in the compose file; secrets reach the script through the environment", () => { + const compose = readFileSync(join(REPO, "bundles", "lemmy", "docker-compose.yml"), "utf8"); + assert.match(compose, /admin_password:\s*"\$\$\{CROW_LEMMY_ADMIN_PASSWORD\}"/); + assert.match(compose, /CROW_LEMMY_ADMIN_PASSWORD:\s*"\$\{LEMMY_ADMIN_PASSWORD\}"/); + const script = compose.slice(compose.indexOf("cat > /config"), compose.indexOf("HJSON\n exec")); + assert.ok(script.length > 100, "found the entrypoint script"); + assert.doesNotMatch(script, /(? { + const out = execFileSync("git", ["ls-files", "bundles/*/docker-compose.yml"], { cwd: REPO, encoding: "utf8" }).trim().split("\n"); + // Documented false positives: file → key, with the reason. + const ALLOW = { + "bundles/searxng/docker-compose.yml": { secret_key: "a sed command that rewrites secret_key from $SEARXNG_SECRET_KEY (no literal)" }, + }; + const bad = []; + for (const rel of out) { + const lines = readFileSync(join(REPO, rel), "utf8").split("\n"); + lines.forEach((l, i) => { + if (/^\s*#/.test(l)) return; + // ${X} / ${X:-fallback} are env references (static :- fallbacks are a separate, + // known item); only a literal written in the file counts here. + l = l.replace(/\$\{[^}]*\}/g, "$REF"); + // KEY: value / KEY=value / key: "value" where KEY names a password and the value is a literal + const m = l.match(/([A-Za-z_]*(?:password|PASSWORD|_SECRET|secret_key)[A-Za-z_]*)\s*[:=]\s*["']?([^"'\s$#{][^"'\s#]*)/); + if (!m) return; + if (/^(true|false|null|file|\/run\/secrets\/)/i.test(m[2])) return; + if (ALLOW[rel]?.[m[1]]) return; + bad.push(`${rel}:${i + 1} ${m[1]}`); + }); + } + assert.deepEqual(bad, [], `literal passwords in compose files:\n ${bad.join("\n ")}`); +}); + +// ---- actual-budget: set the generated password on a NEW local server ---------- +const AB = await import("../bundles/actual-budget/server/bootstrap.js"); +function fakeFetch(routes) { + const calls = []; + const fn = async (url, init = {}) => { + const path = new URL(url).pathname; + calls.push({ path, method: init.method || "GET", body: init.body ? JSON.parse(init.body) : null }); + const r = routes[path]; + if (!r) return { ok: false, status: 404, json: async () => ({}) }; + return { ok: r.status < 400, status: r.status, json: async () => r.body }; + }; + fn.calls = calls; + return fn; +} + +test("actual-budget: a new loopback server is bootstrapped with the generated password, once", async () => { + const f = fakeFetch({ + "/account/needs-bootstrap": { status: 200, body: { status: "ok", data: { bootstrapped: false } } }, + "/account/bootstrap": { status: 200, body: { status: "ok", data: {} } }, + }); + assert.equal(await AB.ensureBootstrapped("http://localhost:5006", "pw-generated", { fetchImpl: f }), "bootstrapped"); + assert.deepEqual(f.calls.map((c) => `${c.method} ${c.path}`), ["GET /account/needs-bootstrap", "POST /account/bootstrap"]); + assert.deepEqual(f.calls[1].body, { password: "pw-generated" }); +}); + +test("actual-budget: an already set-up server is never sent the password", async () => { + const f = fakeFetch({ "/account/needs-bootstrap": { status: 200, body: { status: "ok", data: { bootstrapped: true } } } }); + assert.equal(await AB.ensureBootstrapped("http://127.0.0.1:5006/", "pw", { fetchImpl: f }), "ready"); + assert.equal(f.calls.length, 1); + const raced = fakeFetch({ + "/account/needs-bootstrap": { status: 200, body: { status: "ok", data: { bootstrapped: false } } }, + "/account/bootstrap": { status: 400, body: { status: "error", reason: "already-bootstrapped" } }, + }); + assert.equal(await AB.ensureBootstrapped("http://localhost:5006", "pw", { fetchImpl: raced }), "ready"); +}); + +test("actual-budget: a remote server is never bootstrapped by Crow; no password means no call", async () => { + for (const url of ["http://actual.example.invalid:5006", "http://10.1.2.3:5006", "http://localhost.evil.example:5006", "not a url"]) { + const f = fakeFetch({}); + assert.equal(await AB.ensureBootstrapped(url, "pw", { fetchImpl: f }), "skipped", url); + assert.equal(f.calls.length, 0, url); + } + const f = fakeFetch({}); + assert.equal(await AB.ensureBootstrapped("http://localhost:5006", "", { fetchImpl: f }), "skipped"); + assert.equal(f.calls.length, 0); + assert.equal(AB.isLoopbackUrl("http://[::1]:5006"), true); +}); + +test("actual-budget: a refused bootstrap is an error that names the reason, not the password", async () => { + const f = fakeFetch({ + "/account/needs-bootstrap": { status: 200, body: { status: "ok", data: { bootstrapped: false } } }, + "/account/bootstrap": { status: 400, body: { status: "error", reason: "invalid-password" } }, + }); + await assert.rejects(AB.ensureBootstrapped("http://localhost:5006", "pw-secret-x", { fetchImpl: f }), (err) => /invalid-password/.test(err.message) && !err.message.includes("pw-secret-x")); +}); + +test("actual-budget install runs its postInstall hook (sets the password right away)", () => { + assert.ok(hookRuns.length >= 1, "postInstall hook invoked"); + assert.equal(manifestOf("actual-budget").postInstall.script, "scripts/set-server-password.sh"); +}); + +test("actual-budget: a typed password wins; the generated one only ever goes to a loopback URL", () => { + const env = { ACTUAL_SERVER_PASSWORD: "gen" }; + assert.equal(AB.effectivePassword("http://localhost:5006", env), "gen"); + assert.equal(AB.effectivePassword("https://actual.example.invalid", env), ""); + assert.equal(AB.effectivePassword("https://actual.example.invalid", { ...env, ACTUAL_PASSWORD: "typed" }), "typed"); + assert.equal(AB.effectivePassword("http://127.0.0.1:5006", { ...env, ACTUAL_PASSWORD: "typed" }), "typed"); +}); + +test("set-server-password.sh: bootstraps a new local server once, sends the password only in the body, never prints it", async () => { + const http = await import("node:http"); + const { execFile } = await import("node:child_process"); + let bootstrapped = ""; + const bodies = []; + const srv = http.createServer((req, res) => { + let body = ""; + req.on("data", (d) => { body += d; }); + req.on("end", () => { + res.setHeader("Content-Type", "application/json"); + if (req.url === "/account/needs-bootstrap") return res.end(JSON.stringify({ status: "ok", data: { bootstrapped: !!bootstrapped } })); + if (req.url === "/account/bootstrap") { + bodies.push(body); + if (bootstrapped) return res.end(JSON.stringify({ status: "error", reason: "already-bootstrapped" })); + bootstrapped = JSON.parse(body).password; + return res.end(JSON.stringify({ status: "ok", data: {} })); + } + if (req.url === "/account/login") { + const ok = bootstrapped && JSON.parse(body).password === bootstrapped; + return res.end(JSON.stringify(ok ? { status: "ok", data: { token: "t" } } : { status: "error", reason: "invalid-password" })); + } + res.statusCode = 404; res.end("{}"); + }); + }); + await new Promise((r) => srv.listen(0, "127.0.0.1", r)); + const dir = mkdtempSync(join(tmpdir(), "crow-ab-hook-")); + const pw = "Abc_def-123ghiJKLmnoPQRstuVWXyz0123456789abc"; + writeFileSync(join(dir, ".env"), `ACTUAL_URL=http://localhost:5006\nACTUAL_SERVER_PASSWORD=${pw}\n`); + // async (not spawnSync): the fake server answers on this same event loop + const run = () => new Promise((resolve) => execFile("bash", [join(REPO, "bundles/actual-budget/scripts/set-server-password.sh")], { + env: { PATH: process.env.PATH, CROW_BUNDLE_DIR: dir, ACTUAL_BOOTSTRAP_URL: `http://127.0.0.1:${srv.address().port}`, ACTUAL_BOOTSTRAP_TRIES: "2" }, + encoding: "utf8", + }, (err, stdout, stderr) => resolve({ status: err ? err.code : 0, stdout, stderr }))); + try { + const first = await run(); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /Set the generated password/); + assert.deepEqual(bodies.map((b) => JSON.parse(b)), [{ password: pw }]); + const second = await run(); + assert.match(second.stdout, /already has this password/); + assert.equal(bodies.length, 1, "a set-up server is not bootstrapped again"); + // Reinstall over kept data: a freshly minted password does not open the old server. + writeFileSync(join(dir, ".env"), "ACTUAL_SERVER_PASSWORD=Other_generated-value-0123456789abcdefghijklmn\n"); + const third = await run(); + assert.match(third.stdout, /kept its password from an earlier install/); + assert.equal(bodies.length, 1); + for (const o of [first, second, third]) assert.ok(!o.stdout.includes(pw) && !o.stderr.includes(pw), "never printed"); + } finally { + srv.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("actual-budget: a stale typed password never becomes the new server's password, and login falls back to the generated one on loopback", () => { + const env = { ACTUAL_PASSWORD: "stale-typed", ACTUAL_SERVER_PASSWORD: "generated" }; + assert.deepEqual(AB.loginCandidates("http://localhost:5006", env), ["stale-typed", "generated"]); + assert.deepEqual(AB.loginCandidates("https://actual.example.invalid", env), ["stale-typed"], "the generated one never leaves the machine"); + const server = readFileSync(join(REPO, "bundles/actual-budget/server/server.js"), "utf8"); + assert.match(server, /ensureBootstrapped\(ACTUAL_URL, process\.env\.ACTUAL_SERVER_PASSWORD/, "only the generated password bootstraps"); +}); diff --git a/tests/bundles-install-hardening.test.js b/tests/bundles-install-hardening.test.js index a18b901a4..1bdf22963 100644 --- a/tests/bundles-install-hardening.test.js +++ b/tests/bundles-install-hardening.test.js @@ -84,13 +84,12 @@ test("installBlockingEnvKeys = required AND no default AND compose hard-fails on assert.deepEqual(installBlockingEnvKeys("no-such-bundle"), []); }); -test("real bundles: navidrome is blocked on its music path; phone's generated PHONE_RUNNER_SECRET and gitea's post-install GITEA_TOKEN are not", async () => { +test("real bundles: navidrome's music path defaults inside CROW_HOME (not blocking); phone's generated PHONE_RUNNER_SECRET and gitea's post-install GITEA_TOKEN are not", async () => { _setAppBundlesForTest(new URL("../bundles", import.meta.url).pathname); try { - assert.deepEqual(installBlockingEnvKeys("navidrome"), ["NAVIDROME_MUSIC_PATH"]); + assert.deepEqual(installBlockingEnvKeys("navidrome"), []); const nav = await validateInstall("navidrome", { envVars: {}, requireEnv: true, forceInstall: true }); - assert.equal(nav.code, "missing_required_env"); - assert.deepEqual(nav.extra.missing_env, ["NAVIDROME_MUSIC_PATH"]); + assert.notEqual(nav.code, "missing_required_env", "a blank music path becomes a folder inside CROW_HOME"); // Crow generates the runner secret at install (config-friction stage 1): never asked for. assert.deepEqual(installBlockingEnvKeys("phone"), []); @@ -108,16 +107,15 @@ test("real bundles: navidrome is blocked on its music path; phone's generated PH }); test("real bundles: media-path / admin-password compose hard-fail keys are required, so they block install (B1)", async () => { - // Found 2026-10-02: jellyfin/navidrome/plex/miniflux interpolate these as + // Found 2026-10-02: jellyfin/navidrome/plex(/miniflux, generated since) interpolate these as // ${KEY:?…} but marked them required:false, so a blank install reached // `docker compose up` and failed there. _setAppBundlesForTest(new URL("../bundles", import.meta.url).pathname); try { const expected = { - jellyfin: "JELLYFIN_MEDIA_PATH", - navidrome: "NAVIDROME_MUSIC_PATH", + // jellyfin/navidrome default to a library folder inside CROW_HOME now (default_in_crow_home). plex: "PLEX_MEDIA_PATH", - miniflux: "MINIFLUX_ADMIN_PASSWORD", + // miniflux's admin password is generated now (never install-blocking). }; for (const [id, key] of Object.entries(expected)) { assert.deepEqual(installBlockingEnvKeys(id), [key], id + " blocks on " + key); @@ -507,3 +505,14 @@ test("POST /bundles/api/env: a sync throw inside the handler (.env is a director server.close(); } }); + +test("miniflux: its admin password is generated, so a blank install is not blocked", async () => { + _setAppBundlesForTest(new URL("../bundles", import.meta.url).pathname); + try { + assert.deepEqual(installBlockingEnvKeys("miniflux"), []); + const r = await validateInstall("miniflux", { envVars: {}, requireEnv: true, forceInstall: true }); + assert.notEqual(r.code, "missing_required_env", JSON.stringify(r)); + } finally { + _setAppBundlesForTest(FIXTURES); + } +});