Skip to content

Commit 86da2ba

Browse files
authored
v0.0.10: make central feedback delivery durable (#11)
Signed-off-by: Joseph Yaksich <gitcommit90@users.noreply.github.com>
1 parent 76b44b6 commit 86da2ba

12 files changed

Lines changed: 287 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.10] - 2026-07-25
11+
12+
### Fixed
13+
14+
- Feedback delivery now uses a durable central SQLite collector hosted on
15+
`1helm.com`, with bounded request bodies and attachments, validation,
16+
deduplication, rate limiting, a hidden authenticated inbox, and persistent
17+
systemd state outside versioned website snapshots. This removes the
18+
undeployed Cloudflare Worker route that returned `Not found` in v0.0.9.
19+
- The final stress-test integration pass reconfirmed the full 22-item product
20+
sweep and the live resident follow-up countdown without weakening channel
21+
isolation, provider routing, or the existing app experience.
22+
1023
## [0.0.9] - 2026-07-25
1124

1225
### Added
@@ -279,7 +292,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
279292
notarization, stapled tickets, Gatekeeper verification, persistent
280293
Application Support, and isolated Apple container machines.
281294

282-
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.9...HEAD
295+
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.10...HEAD
296+
[0.0.10]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.10
283297
[0.0.9]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.9
284298
[0.0.8]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.8
285299
[0.0.7]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.7

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
258258
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
259259
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. |
260260
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. |
261-
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.9` | Versioned channel-machine image contract. |
261+
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.10` | Versioned channel-machine image contract. |
262262

263263
### Agent-first JSON CLI
264264

deploy/1helm-site.service

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ WorkingDirectory=/opt/1helm-site/current
1010
Environment=NODE_ENV=production
1111
Environment=SITE_HOST=127.0.0.1
1212
Environment=SITE_PORT=8130
13+
Environment=SITE_DATA_DIR=/var/lib/1helm-site
14+
StateDirectory=1helm-site
15+
StateDirectoryMode=0700
1316
ExecStart=/usr/bin/node site/server.mjs
1417
Restart=always
1518
RestartSec=2

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "1helm",
33
"productName": "1Helm",
4-
"version": "0.0.9",
4+
"version": "0.0.10",
55
"private": true,
66
"type": "module",
77
"license": "AGPL-3.0-only",

site/server.mjs

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createHash } from "node:crypto";
22
import { createServer } from "node:http";
3-
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
3+
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
44
import { extname, join, normalize, resolve } from "node:path";
5+
import { DatabaseSync } from "node:sqlite";
56
import { pages, redirects, sitemapPaths } from "./content.mjs";
67
import { renderPage } from "./template.mjs";
78

@@ -25,8 +26,15 @@ const ORIGIN = "https://1helm.com";
2526
const REPO = "gitcommit90/1Helm";
2627
const RELEASE_PAGE = `https://github.com/${REPO}/releases/latest`;
2728
const RELEASE_CACHE_MS = 10 * 60_000;
29+
const FEEDBACK_DATA_DIR = resolve(process.env.SITE_DATA_DIR || join(ROOT, ".site-data"));
30+
const FEEDBACK_ADMIN_TOKEN = String(process.env.SITE_FEEDBACK_ADMIN_TOKEN || "");
31+
const FEEDBACK_BODY_LIMIT = 15 * 1024 * 1024;
32+
const FEEDBACK_RATE_LIMIT = 30;
33+
const FEEDBACK_RATE_WINDOW_MS = 60_000;
2834

2935
let releaseCache = { at: 0, assets: null };
36+
let feedbackDatabase;
37+
const feedbackRate = new Map();
3038
async function latestReleaseAssets() {
3139
if (Date.now() - releaseCache.at < RELEASE_CACHE_MS && releaseCache.assets) return releaseCache.assets;
3240
const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
@@ -74,6 +82,158 @@ function answer(res, status, body, headers = {}) {
7482
res.end(body);
7583
}
7684

85+
function feedbackDb() {
86+
if (feedbackDatabase) return feedbackDatabase;
87+
mkdirSync(FEEDBACK_DATA_DIR, { recursive: true, mode: 0o700 });
88+
feedbackDatabase = new DatabaseSync(join(FEEDBACK_DATA_DIR, "feedback.db"));
89+
feedbackDatabase.exec(`
90+
PRAGMA journal_mode=WAL;
91+
PRAGMA foreign_keys=ON;
92+
CREATE TABLE IF NOT EXISTS feedback_reports (
93+
public_id TEXT PRIMARY KEY,
94+
installation_id TEXT NOT NULL,
95+
workspace_name TEXT NOT NULL DEFAULT '',
96+
comment TEXT NOT NULL,
97+
diagnostics TEXT NOT NULL DEFAULT '{}',
98+
attachment_count INTEGER NOT NULL DEFAULT 0,
99+
created_at INTEGER NOT NULL,
100+
received_at INTEGER NOT NULL
101+
);
102+
CREATE INDEX IF NOT EXISTS idx_feedback_received ON feedback_reports(received_at DESC);
103+
CREATE TABLE IF NOT EXISTS feedback_attachments (
104+
id INTEGER PRIMARY KEY AUTOINCREMENT,
105+
report_id TEXT NOT NULL REFERENCES feedback_reports(public_id) ON DELETE CASCADE,
106+
name TEXT NOT NULL,
107+
mime TEXT NOT NULL,
108+
size INTEGER NOT NULL,
109+
data BLOB NOT NULL,
110+
created_at INTEGER NOT NULL
111+
);
112+
`);
113+
return feedbackDatabase;
114+
}
115+
116+
function feedbackAddress(req) {
117+
return String(req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"] || req.socket.remoteAddress || "unknown").split(",")[0].trim();
118+
}
119+
120+
function feedbackRateLimited(req) {
121+
const stamp = Date.now();
122+
const key = feedbackAddress(req);
123+
const current = feedbackRate.get(key);
124+
if (!current || stamp - current.started >= FEEDBACK_RATE_WINDOW_MS) {
125+
feedbackRate.set(key, { started: stamp, count: 1 });
126+
if (feedbackRate.size > 2_000) {
127+
for (const [address, bucket] of feedbackRate) if (stamp - bucket.started >= FEEDBACK_RATE_WINDOW_MS) feedbackRate.delete(address);
128+
}
129+
return false;
130+
}
131+
current.count += 1;
132+
return current.count > FEEDBACK_RATE_LIMIT;
133+
}
134+
135+
function readJsonBody(req, limit = FEEDBACK_BODY_LIMIT) {
136+
return new Promise((resolveBody, rejectBody) => {
137+
let size = 0;
138+
let rejected = false;
139+
const chunks = [];
140+
req.on("data", (chunk) => {
141+
size += chunk.length;
142+
if (size > limit) {
143+
if (!rejected) rejectBody(Object.assign(new Error("Feedback payload is too large."), { status: 413 }));
144+
rejected = true;
145+
return;
146+
}
147+
chunks.push(chunk);
148+
});
149+
req.on("end", () => {
150+
if (rejected) return;
151+
try { resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); }
152+
catch { rejectBody(Object.assign(new Error("Feedback must be valid JSON."), { status: 400 })); }
153+
});
154+
req.on("error", rejectBody);
155+
});
156+
}
157+
158+
function validatedFeedback(body) {
159+
const source = body && typeof body === "object" && !Array.isArray(body) ? body : {};
160+
const publicId = String(source.public_id || "");
161+
const installationId = String(source.installation_id || "");
162+
const workspaceName = String(source.workspace_name || "").trim().slice(0, 100);
163+
const comment = String(source.comment || "").trim().slice(0, 10_000);
164+
const diagnostics = source.diagnostics && typeof source.diagnostics === "object" && !Array.isArray(source.diagnostics) ? source.diagnostics : {};
165+
const attachments = Array.isArray(source.attachments) ? source.attachments.slice(0, 3) : [];
166+
if (!/^fb_[a-f0-9]{24}$/.test(publicId) || !/^[a-f0-9]{16}$/.test(installationId)) {
167+
throw Object.assign(new Error("Feedback source could not be verified."), { status: 400 });
168+
}
169+
if (!comment && !attachments.length) throw Object.assign(new Error("Feedback is empty."), { status: 400 });
170+
const diagnosticsJson = JSON.stringify(diagnostics);
171+
if (Buffer.byteLength(diagnosticsJson) > 64 * 1024) throw Object.assign(new Error("Diagnostics are too large."), { status: 413 });
172+
let total = 0;
173+
const cleanAttachments = attachments.map((raw) => {
174+
const attachment = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
175+
const size = Number(attachment.size || 0);
176+
const data = String(attachment.data || "");
177+
const validBase64 = data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(data);
178+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
179+
const decodedSize = data.length ? (data.length / 4) * 3 - padding : 0;
180+
if (!Number.isSafeInteger(size) || !validBase64 || decodedSize !== size
181+
|| size < 0 || size > 5 * 1024 * 1024 || data.length > 7 * 1024 * 1024) {
182+
throw Object.assign(new Error("A feedback attachment is too large."), { status: 413 });
183+
}
184+
total += size;
185+
return {
186+
name: String(attachment.name || "attachment").slice(0, 255),
187+
mime: String(attachment.mime || "application/octet-stream").slice(0, 120),
188+
size,
189+
data: Buffer.from(data, "base64"),
190+
};
191+
});
192+
if (total > 10 * 1024 * 1024) throw Object.assign(new Error("Feedback attachments are too large."), { status: 413 });
193+
return { publicId, installationId, workspaceName, comment, diagnosticsJson, attachments: cleanAttachments };
194+
}
195+
196+
function saveFeedback(input) {
197+
const database = feedbackDb();
198+
const timestamp = Date.now();
199+
database.exec("BEGIN IMMEDIATE");
200+
try {
201+
const inserted = database.prepare(`INSERT OR IGNORE INTO feedback_reports
202+
(public_id,installation_id,workspace_name,comment,diagnostics,attachment_count,created_at,received_at)
203+
VALUES (?,?,?,?,?,?,?,?)`).run(
204+
input.publicId, input.installationId, input.workspaceName, input.comment, input.diagnosticsJson,
205+
input.attachments.length, timestamp, timestamp,
206+
);
207+
if (inserted.changes) {
208+
const addAttachment = database.prepare(`INSERT INTO feedback_attachments
209+
(report_id,name,mime,size,data,created_at) VALUES (?,?,?,?,?,?)`);
210+
for (const attachment of input.attachments) addAttachment.run(
211+
input.publicId, attachment.name, attachment.mime, attachment.size, attachment.data, timestamp,
212+
);
213+
}
214+
database.exec("COMMIT");
215+
} catch (error) {
216+
database.exec("ROLLBACK");
217+
throw error;
218+
}
219+
}
220+
221+
function feedbackInbox(req, res) {
222+
const token = String(req.headers.authorization || "").replace(/^Bearer\s+/i, "");
223+
if (!FEEDBACK_ADMIN_TOKEN || token !== FEEDBACK_ADMIN_TOKEN) {
224+
answer(res, 404, JSON.stringify({ error: "Not found" }), { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
225+
return;
226+
}
227+
const reports = feedbackDb().prepare(`SELECT public_id,installation_id,workspace_name,comment,diagnostics,
228+
attachment_count,created_at created,received_at FROM feedback_reports ORDER BY received_at DESC LIMIT 500`).all().map((report) => ({
229+
...report,
230+
diagnostics: JSON.parse(String(report.diagnostics || "{}")),
231+
state: "delivered",
232+
attachments: [],
233+
}));
234+
answer(res, 200, JSON.stringify({ reports }), { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
235+
}
236+
77237
function redirect(res, location, status = 302) {
78238
answer(res, status, "", { location, "cache-control": "no-store" });
79239
}
@@ -105,9 +265,36 @@ function serveFile(req, res, file, cache = "public, max-age=86400") {
105265
return true;
106266
}
107267

108-
const server = createServer((req, res) => {
268+
const server = createServer(async (req, res) => {
109269
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
110270
const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, "") : "/";
271+
if (path === "/api/feedback" && req.method === "POST") {
272+
if (feedbackRateLimited(req)) {
273+
answer(res, 429, JSON.stringify({ error: "Too many feedback reports. Try again shortly." }), {
274+
"content-type": "application/json; charset=utf-8",
275+
"cache-control": "no-store",
276+
});
277+
return;
278+
}
279+
try {
280+
const input = validatedFeedback(await readJsonBody(req));
281+
saveFeedback(input);
282+
answer(res, 202, JSON.stringify({ id: input.publicId }), {
283+
"content-type": "application/json; charset=utf-8",
284+
"cache-control": "no-store",
285+
});
286+
} catch (error) {
287+
answer(res, Number(error.status) || 500, JSON.stringify({ error: Number(error.status) ? error.message : "Feedback could not be saved." }), {
288+
"content-type": "application/json; charset=utf-8",
289+
"cache-control": "no-store",
290+
});
291+
}
292+
return;
293+
}
294+
if (path === "/api/feedback" && req.method === "GET") {
295+
feedbackInbox(req, res);
296+
return;
297+
}
111298
if (!['GET', 'HEAD'].includes(req.method || 'GET')) {
112299
answer(res, 405, "Method not allowed", { "content-type": "text/plain; charset=utf-8", allow: "GET, HEAD" });
113300
return;

src/server/channel-computers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0";
6767
export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`;
6868
export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`;
6969
export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714";
70-
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.9";
70+
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.10";
7171
const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[];
7272
const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1";
7373
const LXC_HELPER_CANDIDATES = [

src/server/db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,7 @@ export function migrate(): void {
939939
const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc";
940940
const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend);
941941
const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend;
942-
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.9");
942+
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.10");
943943
// Earlier Linux/Windows releases persisted the compatibility `native`
944944
// seam into every channel row. A production host update must actually
945945
// move those rows onto the platform isolation backend; changing the unit's

src/server/feedback.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { basename, join } from "node:path";
55
import { DATA_DIR, UPLOAD_DIR, now, q, q1, run, type Row } from "./db.ts";
66
import { installedAppVersion } from "./updates.ts";
77

8-
const COLLECTOR = String(process.env.HELM_FEEDBACK_URL || "https://provision.1helm.com/v1/feedback").replace(/\/+$/, "");
8+
export const DEFAULT_FEEDBACK_COLLECTOR = "https://1helm.com/api/feedback";
9+
const COLLECTOR = String(process.env.HELM_FEEDBACK_URL || DEFAULT_FEEDBACK_COLLECTOR).replace(/\/+$/, "");
910
const ADMIN_TOKEN = String(process.env.HELM_FEEDBACK_ADMIN_TOKEN || "");
1011
const MAX_FILES = 3;
1112
const MAX_FILE_BYTES = 5 * 1024 * 1024;

test/channel-computers.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive
168168
test("runtime digest and packaged image recipe stay pinned", async () => {
169169
assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714");
170170
assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/);
171-
assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.9");
171+
assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.10");
172172
const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8");
173173
assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets");
174174
const image = await readFile(join(root, "container", "Containerfile"), "utf8");

0 commit comments

Comments
 (0)