Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions deploy/1helm-site.service
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
191 changes: 189 additions & 2 deletions site/server.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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`, {
Expand Down Expand Up @@ -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" });
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/server/channel-computers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion src/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/server/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion test/channel-computers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions test/feedback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading