From 432de42ddaeab11ddf994356b1b69f83f10bb974 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Sat, 25 Jul 2026 08:06:29 +0000 Subject: [PATCH] fix: make central feedback delivery durable Signed-off-by: Joseph Yaksich --- CHANGELOG.md | 16 ++- README.md | 2 +- deploy/1helm-site.service | 3 + package-lock.json | 4 +- package.json | 2 +- site/server.mjs | 191 +++++++++++++++++++++++++++++++- src/server/channel-computers.ts | 2 +- src/server/db.ts | 2 +- src/server/feedback.ts | 3 +- test/channel-computers.mjs | 2 +- test/feedback.mjs | 4 + test/site.mjs | 68 +++++++++++- 12 files changed, 287 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7bf3a..d112c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.10] - 2026-07-25 + +### Fixed + +- Feedback delivery now uses a durable central SQLite collector hosted on + `1helm.com`, with bounded request bodies and attachments, validation, + deduplication, rate limiting, a hidden authenticated inbox, and persistent + systemd state outside versioned website snapshots. This removes the + undeployed Cloudflare Worker route that returned `Not found` in v0.0.9. +- The final stress-test integration pass reconfirmed the full 22-item product + sweep and the live resident follow-up countdown without weakening channel + isolation, provider routing, or the existing app experience. + ## [0.0.9] - 2026-07-25 ### Added @@ -279,7 +292,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.9...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.10...HEAD +[0.0.10]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.10 [0.0.9]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.9 [0.0.8]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.8 [0.0.7]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.7 diff --git a/README.md b/README.md index 1efb2cc..e997c35 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.9` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.10` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/deploy/1helm-site.service b/deploy/1helm-site.service index 16ab268..7d8ea75 100644 --- a/deploy/1helm-site.service +++ b/deploy/1helm-site.service @@ -10,6 +10,9 @@ WorkingDirectory=/opt/1helm-site/current Environment=NODE_ENV=production Environment=SITE_HOST=127.0.0.1 Environment=SITE_PORT=8130 +Environment=SITE_DATA_DIR=/var/lib/1helm-site +StateDirectory=1helm-site +StateDirectoryMode=0700 ExecStart=/usr/bin/node site/server.mjs Restart=always RestartSec=2 diff --git a/package-lock.json b/package-lock.json index 34a5ccd..299fe53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.9", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.9", + "version": "0.0.10", "license": "AGPL-3.0-only", "hasInstallScript": true, "dependencies": { diff --git a/package.json b/package.json index 17357e2..4e7aac7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.9", + "version": "0.0.10", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/site/server.mjs b/site/server.mjs index dd441d3..03c9cdd 100644 --- a/site/server.mjs +++ b/site/server.mjs @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; import { createServer } from "node:http"; -import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; +import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { extname, join, normalize, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { pages, redirects, sitemapPaths } from "./content.mjs"; import { renderPage } from "./template.mjs"; @@ -25,8 +26,15 @@ const ORIGIN = "https://1helm.com"; const REPO = "gitcommit90/1Helm"; const RELEASE_PAGE = `https://github.com/${REPO}/releases/latest`; const RELEASE_CACHE_MS = 10 * 60_000; +const FEEDBACK_DATA_DIR = resolve(process.env.SITE_DATA_DIR || join(ROOT, ".site-data")); +const FEEDBACK_ADMIN_TOKEN = String(process.env.SITE_FEEDBACK_ADMIN_TOKEN || ""); +const FEEDBACK_BODY_LIMIT = 15 * 1024 * 1024; +const FEEDBACK_RATE_LIMIT = 30; +const FEEDBACK_RATE_WINDOW_MS = 60_000; let releaseCache = { at: 0, assets: null }; +let feedbackDatabase; +const feedbackRate = new Map(); async function latestReleaseAssets() { if (Date.now() - releaseCache.at < RELEASE_CACHE_MS && releaseCache.assets) return releaseCache.assets; const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { @@ -74,6 +82,158 @@ function answer(res, status, body, headers = {}) { res.end(body); } +function feedbackDb() { + if (feedbackDatabase) return feedbackDatabase; + mkdirSync(FEEDBACK_DATA_DIR, { recursive: true, mode: 0o700 }); + feedbackDatabase = new DatabaseSync(join(FEEDBACK_DATA_DIR, "feedback.db")); + feedbackDatabase.exec(` + PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS feedback_reports ( + public_id TEXT PRIMARY KEY, + installation_id TEXT NOT NULL, + workspace_name TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL, + diagnostics TEXT NOT NULL DEFAULT '{}', + attachment_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + received_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_feedback_received ON feedback_reports(received_at DESC); + CREATE TABLE IF NOT EXISTS feedback_attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + report_id TEXT NOT NULL REFERENCES feedback_reports(public_id) ON DELETE CASCADE, + name TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + data BLOB NOT NULL, + created_at INTEGER NOT NULL + ); + `); + return feedbackDatabase; +} + +function feedbackAddress(req) { + return String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || req.socket.remoteAddress || "unknown").split(",")[0].trim(); +} + +function feedbackRateLimited(req) { + const stamp = Date.now(); + const key = feedbackAddress(req); + const current = feedbackRate.get(key); + if (!current || stamp - current.started >= FEEDBACK_RATE_WINDOW_MS) { + feedbackRate.set(key, { started: stamp, count: 1 }); + if (feedbackRate.size > 2_000) { + for (const [address, bucket] of feedbackRate) if (stamp - bucket.started >= FEEDBACK_RATE_WINDOW_MS) feedbackRate.delete(address); + } + return false; + } + current.count += 1; + return current.count > FEEDBACK_RATE_LIMIT; +} + +function readJsonBody(req, limit = FEEDBACK_BODY_LIMIT) { + return new Promise((resolveBody, rejectBody) => { + let size = 0; + let rejected = false; + const chunks = []; + req.on("data", (chunk) => { + size += chunk.length; + if (size > limit) { + if (!rejected) rejectBody(Object.assign(new Error("Feedback payload is too large."), { status: 413 })); + rejected = true; + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (rejected) return; + try { resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } + catch { rejectBody(Object.assign(new Error("Feedback must be valid JSON."), { status: 400 })); } + }); + req.on("error", rejectBody); + }); +} + +function validatedFeedback(body) { + const source = body && typeof body === "object" && !Array.isArray(body) ? body : {}; + const publicId = String(source.public_id || ""); + const installationId = String(source.installation_id || ""); + const workspaceName = String(source.workspace_name || "").trim().slice(0, 100); + const comment = String(source.comment || "").trim().slice(0, 10_000); + const diagnostics = source.diagnostics && typeof source.diagnostics === "object" && !Array.isArray(source.diagnostics) ? source.diagnostics : {}; + const attachments = Array.isArray(source.attachments) ? source.attachments.slice(0, 3) : []; + if (!/^fb_[a-f0-9]{24}$/.test(publicId) || !/^[a-f0-9]{16}$/.test(installationId)) { + throw Object.assign(new Error("Feedback source could not be verified."), { status: 400 }); + } + if (!comment && !attachments.length) throw Object.assign(new Error("Feedback is empty."), { status: 400 }); + const diagnosticsJson = JSON.stringify(diagnostics); + if (Buffer.byteLength(diagnosticsJson) > 64 * 1024) throw Object.assign(new Error("Diagnostics are too large."), { status: 413 }); + let total = 0; + const cleanAttachments = attachments.map((raw) => { + const attachment = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; + const size = Number(attachment.size || 0); + const data = String(attachment.data || ""); + const validBase64 = data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(data); + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + const decodedSize = data.length ? (data.length / 4) * 3 - padding : 0; + if (!Number.isSafeInteger(size) || !validBase64 || decodedSize !== size + || size < 0 || size > 5 * 1024 * 1024 || data.length > 7 * 1024 * 1024) { + throw Object.assign(new Error("A feedback attachment is too large."), { status: 413 }); + } + total += size; + return { + name: String(attachment.name || "attachment").slice(0, 255), + mime: String(attachment.mime || "application/octet-stream").slice(0, 120), + size, + data: Buffer.from(data, "base64"), + }; + }); + if (total > 10 * 1024 * 1024) throw Object.assign(new Error("Feedback attachments are too large."), { status: 413 }); + return { publicId, installationId, workspaceName, comment, diagnosticsJson, attachments: cleanAttachments }; +} + +function saveFeedback(input) { + const database = feedbackDb(); + const timestamp = Date.now(); + database.exec("BEGIN IMMEDIATE"); + try { + const inserted = database.prepare(`INSERT OR IGNORE INTO feedback_reports + (public_id,installation_id,workspace_name,comment,diagnostics,attachment_count,created_at,received_at) + VALUES (?,?,?,?,?,?,?,?)`).run( + input.publicId, input.installationId, input.workspaceName, input.comment, input.diagnosticsJson, + input.attachments.length, timestamp, timestamp, + ); + if (inserted.changes) { + const addAttachment = database.prepare(`INSERT INTO feedback_attachments + (report_id,name,mime,size,data,created_at) VALUES (?,?,?,?,?,?)`); + for (const attachment of input.attachments) addAttachment.run( + input.publicId, attachment.name, attachment.mime, attachment.size, attachment.data, timestamp, + ); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } +} + +function feedbackInbox(req, res) { + const token = String(req.headers.authorization || "").replace(/^Bearer\s+/i, ""); + if (!FEEDBACK_ADMIN_TOKEN || token !== FEEDBACK_ADMIN_TOKEN) { + answer(res, 404, JSON.stringify({ error: "Not found" }), { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + return; + } + const reports = feedbackDb().prepare(`SELECT public_id,installation_id,workspace_name,comment,diagnostics, + attachment_count,created_at created,received_at FROM feedback_reports ORDER BY received_at DESC LIMIT 500`).all().map((report) => ({ + ...report, + diagnostics: JSON.parse(String(report.diagnostics || "{}")), + state: "delivered", + attachments: [], + })); + answer(res, 200, JSON.stringify({ reports }), { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); +} + function redirect(res, location, status = 302) { answer(res, status, "", { location, "cache-control": "no-store" }); } @@ -105,9 +265,36 @@ function serveFile(req, res, file, cache = "public, max-age=86400") { return true; } -const server = createServer((req, res) => { +const server = createServer(async (req, res) => { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : "/"; + if (path === "/api/feedback" && req.method === "POST") { + if (feedbackRateLimited(req)) { + answer(res, 429, JSON.stringify({ error: "Too many feedback reports. Try again shortly." }), { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + return; + } + try { + const input = validatedFeedback(await readJsonBody(req)); + saveFeedback(input); + answer(res, 202, JSON.stringify({ id: input.publicId }), { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + } catch (error) { + answer(res, Number(error.status) || 500, JSON.stringify({ error: Number(error.status) ? error.message : "Feedback could not be saved." }), { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + } + return; + } + if (path === "/api/feedback" && req.method === "GET") { + feedbackInbox(req, res); + return; + } if (!['GET', 'HEAD'].includes(req.method || 'GET')) { answer(res, 405, "Method not allowed", { "content-type": "text/plain; charset=utf-8", allow: "GET, HEAD" }); return; diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 53786e8..436e4bd 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.9"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.10"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ diff --git a/src/server/db.ts b/src/server/db.ts index 719f92e..40676d1 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -939,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.9"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.10"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/src/server/feedback.ts b/src/server/feedback.ts index 5316289..a9f395a 100644 --- a/src/server/feedback.ts +++ b/src/server/feedback.ts @@ -5,7 +5,8 @@ import { basename, join } from "node:path"; import { DATA_DIR, UPLOAD_DIR, now, q, q1, run, type Row } from "./db.ts"; import { installedAppVersion } from "./updates.ts"; -const COLLECTOR = String(process.env.HELM_FEEDBACK_URL || "https://provision.1helm.com/v1/feedback").replace(/\/+$/, ""); +export const DEFAULT_FEEDBACK_COLLECTOR = "https://1helm.com/api/feedback"; +const COLLECTOR = String(process.env.HELM_FEEDBACK_URL || DEFAULT_FEEDBACK_COLLECTOR).replace(/\/+$/, ""); const ADMIN_TOKEN = String(process.env.HELM_FEEDBACK_ADMIN_TOKEN || ""); const MAX_FILES = 3; const MAX_FILE_BYTES = 5 * 1024 * 1024; diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index e2a9e0d..44b9572 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.9"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.10"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/feedback.mjs b/test/feedback.mjs index 0938506..8319771 100644 --- a/test/feedback.mjs +++ b/test/feedback.mjs @@ -14,6 +14,10 @@ const userId = run("INSERT INTO users (username,pass,display,is_admin,created) V const botId = run("INSERT INTO bots (name,model,created) VALUES ('feedback-agent','mock',?)", now()).lastInsertRowid; const agentId = run("INSERT INTO agents (bot_id,kind,name,status,created) VALUES (?,'skipper','feedback-agent','ready',?)", botId, now()).lastInsertRowid; +test("feedback defaults to the host-controlled central collector", () => { + assert.equal(feedback.DEFAULT_FEEDBACK_COLLECTOR, "https://1helm.com/api/feedback"); +}); + test("feedback diagnostics are opt-in, privacy-bounded, and attachments are durable", () => { const token = "a".repeat(40); writeFileSync(join(UPLOAD_DIR, token), "image"); diff --git a/test/site.mjs b/test/site.mjs index bb15a6f..f742de3 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { execFileSync, spawn } from "node:child_process"; import { createServer } from "node:net"; -import { accessSync, constants, readFileSync } from "node:fs"; +import { accessSync, constants, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import test from "node:test"; const root = new URL("..", import.meta.url).pathname; @@ -56,6 +59,67 @@ test("standalone 1helm.com website serves independent product and documentation } finally { child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)); } }); +test("public feedback intake validates, deduplicates, and persists to the website state database", async () => { + const state = mkdtempSync(join(tmpdir(), "1helm-site-feedback-")); + const token = "feedback-test-admin-token"; + const publicId = `fb_${"a".repeat(24)}`; + let child; + const start = async () => { + const port = await freePort(); + child = spawn(process.execPath, ["site/server.mjs"], { + cwd: root, + env: { ...process.env, SITE_PORT: String(port), SITE_DATA_DIR: state, SITE_FEEDBACK_ADMIN_TOKEN: token }, + stdio: ["ignore", "pipe", "pipe"], + }); + const base = `http://127.0.0.1:${port}`; + await waitFor(`${base}/health`); + return base; + }; + const stop = async () => { + if (!child || child.exitCode != null) return; + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); + }; + try { + let base = await start(); + const invalid = await fetch(`${base}/api/feedback`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); + assert.equal(invalid.status, 400); + assert.match((await invalid.json()).error, /source could not be verified/i); + const report = { + public_id: publicId, + installation_id: "b".repeat(16), + workspace_name: "Feedback contract", + comment: "first durable report", + diagnostics: { version: "test" }, + attachments: [{ name: "proof.txt", mime: "text/plain", size: 3, data: "YWJj" }], + }; + const saved = await fetch(`${base}/api/feedback`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(report) }); + assert.equal(saved.status, 202); + assert.equal((await saved.json()).id, publicId); + const duplicate = await fetch(`${base}/api/feedback`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...report, comment: "must not overwrite" }) }); + assert.equal(duplicate.status, 202); + assert.equal((await fetch(`${base}/api/feedback`)).status, 404, "the central inbox remains hidden without its server-side bearer token"); + await stop(); + + base = await start(); + const inboxResponse = await fetch(`${base}/api/feedback`, { headers: { authorization: `Bearer ${token}` } }); + assert.equal(inboxResponse.status, 200); + const inbox = await inboxResponse.json(); + assert.equal(inbox.reports.length, 1); + assert.equal(inbox.reports[0].public_id, publicId); + assert.equal(inbox.reports[0].comment, "first durable report"); + assert.equal(inbox.reports[0].attachment_count, 1); + const database = new DatabaseSync(join(state, "feedback.db")); + assert.equal(database.prepare("SELECT COUNT(*) count FROM feedback_reports").get().count, 1); + assert.equal(database.prepare("SELECT COUNT(*) count FROM feedback_attachments").get().count, 1); + assert.equal(Buffer.from(database.prepare("SELECT data FROM feedback_attachments").get().data).toString(), "abc"); + database.close(); + } finally { + await stop(); + rmSync(state, { recursive: true, force: true }); + } +}); + test("installer assets are explicit and syntax-valid", () => { accessSync(`${root}/site/public/install.sh`, constants.R_OK); const installer = readFileSync(`${root}/site/public/install.sh`, "utf8"); @@ -127,6 +191,8 @@ test("standalone deployment runs the website and tunnel without root process aut const tunnelUnit = readFileSync(`${root}/deploy/1helm-site-cloudflared.service`, "utf8"); const tunnelConfig = readFileSync(`${root}/deploy/config-1helm-site.yml.example`, "utf8"); assert.match(siteUnit, /DynamicUser=yes/); + assert.match(siteUnit, /StateDirectory=1helm-site/); + assert.match(siteUnit, /SITE_DATA_DIR=\/var\/lib\/1helm-site/); assert.match(siteUnit, /ProtectHome=yes/); assert.match(tunnelUnit, /DynamicUser=yes/); assert.match(tunnelUnit, /LoadCredential=1helm-site-tunnel\.json:\/etc\/cloudflared\/1helm-site-tunnel\.json/);