Compare commits

...
Author SHA1 Message Date
esengine 7943fac193 feat(crash-worker): keep feedback disabled until both secrets exist; migrate in deploy
Refs #11424
2026-09-30 07:48:46 -07:00
esengine a6cf72080f feat(crash-worker): in-app feedback loop endpoints (submit, mine, admin, attachments)
Adds the worker side of the feedback loop: D1 feedback table, install-token HMAC, magic-byte checked R2 attachments, per-install/IP/global limits, a kill switch and the converter admin endpoints. Refs #11424
2026-09-30 07:42:53 -07:00
18 changed files with 928 additions and 17 deletions

No files matched your search

+10
View File
@@ -36,6 +36,16 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify feedback D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback.sql
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ('feedback','feedback_status_created','feedback_install_created')")
for name in feedback feedback_status_created feedback_install_created; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Deploy
working-directory: workers/crash-report
env:
+24
View File
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt TEXT NOT NULL UNIQUE,
install_hash TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
category TEXT NOT NULL,
body TEXT NOT NULL,
display_name TEXT NOT NULL,
contact TEXT NOT NULL DEFAULT '',
env_json TEXT NOT NULL DEFAULT '{}',
attachments_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'received',
issue_number INTEGER,
issue_url TEXT,
resolved_version TEXT,
duplicate_of TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (install_hash, idempotency_key)
);
CREATE INDEX IF NOT EXISTS feedback_status_created ON feedback (status, created_at);
CREATE INDEX IF NOT EXISTS feedback_install_created ON feedback (install_hash, created_at);
CREATE INDEX IF NOT EXISTS feedback_status_version ON feedback (status, resolved_version, created_at);
+26
View File
@@ -254,3 +254,29 @@ CREATE TABLE IF NOT EXISTS audit_log (
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
-- In-app feedback loop (also migrate-feedback.sql).
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt TEXT NOT NULL UNIQUE,
install_hash TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
category TEXT NOT NULL,
body TEXT NOT NULL,
display_name TEXT NOT NULL,
contact TEXT NOT NULL DEFAULT '',
env_json TEXT NOT NULL DEFAULT '{}',
attachments_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'received',
issue_number INTEGER,
issue_url TEXT,
resolved_version TEXT,
duplicate_of TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (install_hash, idempotency_key)
);
CREATE INDEX IF NOT EXISTS feedback_status_created ON feedback (status, created_at);
CREATE INDEX IF NOT EXISTS feedback_install_created ON feedback (install_hash, created_at);
CREATE INDEX IF NOT EXISTS feedback_status_version ON feedback (status, resolved_version, created_at);
+9
View File
@@ -11,6 +11,15 @@ export interface Env {
PING_LIMITER: RateLimiter;
METRICS_LIMITER: RateLimiter;
WRITE_LIMITER?: RateLimiter;
// In-app feedback loop. FEEDBACK_ENABLED="false" is the kill switch for
// submissions. FEEDBACK_TOKEN_SECRET signs install tokens and
// FEEDBACK_ADMIN_TOKEN gates the converter endpoints; both are worker secrets.
FEEDBACK_LIMITER?: RateLimiter;
FEEDBACK_BUDGET_LIMITER?: RateLimiter;
FEEDBACK_R2?: R2Bucket;
FEEDBACK_ENABLED?: string;
FEEDBACK_TOKEN_SECRET?: string;
FEEDBACK_ADMIN_TOKEN?: string;
ADMIN_EMAILS?: string;
// Shared identity service (id.reasonix.io) and the site that hosts its login
// page (reasonix.io). Overridable for local dev.
+225
View File
@@ -0,0 +1,225 @@
// @ts-expect-error Node 22+ provides node:sqlite; Worker production code does not import it.
import { DatabaseSync } from "node:sqlite";
import { beforeEach, describe, expect, it } from "vitest";
import type { Env } from "./env";
import { handleFeedbackRoute } from "./feedback_routes";
import feedbackMigrationSQL from "../migrate-feedback.sql?raw";
const ADMIN = "admin-secret";
function d1(db: DatabaseSync): D1Database {
const stmt = (sql: string, args: unknown[] = []) => ({
bind: (...a: unknown[]) => stmt(sql, a),
first: async () => db.prepare(sql).get(...args) ?? null,
all: async () => ({ results: db.prepare(sql).all(...args) }),
run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
});
return { prepare: (sql: string) => stmt(sql) } as unknown as D1Database;
}
function fakeR2() {
const objects = new Map<string, { body: Uint8Array; contentType: string }>();
const bucket = {
put: async (k: string, body: Uint8Array, o: { httpMetadata: { contentType: string } }) => void objects.set(k, { body, contentType: o.httpMetadata.contentType }),
get: async (k: string) => {
const o = objects.get(k);
return o ? { body: o.body, httpMetadata: { contentType: o.contentType } } : null;
},
};
return { bucket: bucket as unknown as R2Bucket, objects };
}
let env: Env;
let objects: Map<string, unknown>;
let ipAllowed = true;
beforeEach(() => {
const db = new DatabaseSync(":memory:");
db.exec(feedbackMigrationSQL);
const r2 = fakeR2();
objects = r2.objects;
ipAllowed = true;
env = {
DB: d1(db),
FEEDBACK_R2: r2.bucket,
FEEDBACK_LIMITER: { limit: async () => ({ success: ipAllowed }) },
FEEDBACK_TOKEN_SECRET: "token-secret",
FEEDBACK_ADMIN_TOKEN: ADMIN,
FEEDBACK_ENABLED: "true",
} as unknown as Env;
});
const call = (path: string, init: RequestInit = {}) => handleFeedbackRoute(new Request(`https://crash.test${path}`, init), env) as Promise<Response>;
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
call(path, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json", ...headers } });
const admin = { authorization: `Bearer ${ADMIN}` };
const PNG_B64 = btoa(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3));
let seq = 0;
const submission = (over: Record<string, unknown> = {}) => ({
idempotencyKey: `key-${++seq}-aaaaaaaa`,
installId: "install-aaaaaaaaaaaaaaaa",
category: "bug",
body: "the composer freezes",
displayName: "Ada",
env: { version: "v2.24.0", surface: "studio" },
...over,
});
const errCode = async (r: Response) => ((await r.json()) as { error: { code: string } }).error.code;
describe("POST /v1/feedback", () => {
it("accepts a submission and returns a receipt and install token", async () => {
const res = await post("/v1/feedback", submission());
expect(res.status).toBe(201);
const j = (await res.json()) as { receipt: string; status: string; installToken: string };
expect(j.receipt).toMatch(/^FB-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);
expect(j.status).toBe("received");
expect(j.installToken.length).toBeGreaterThan(20);
});
it("replays an idempotency key without creating a second row", async () => {
const s = submission();
const a = (await (await post("/v1/feedback", s)).json()) as { receipt: string };
const res = await post("/v1/feedback", s);
expect(res.status).toBe(200);
expect(((await res.json()) as { receipt: string }).receipt).toBe(a.receipt);
});
it("caps an install at three submissions per hour", async () => {
for (let i = 0; i < 3; i++) expect((await post("/v1/feedback", submission())).status).toBe(201);
const res = await post("/v1/feedback", submission());
expect(res.status).toBe(429);
expect(await errCode(res)).toBe("feedback.rate_limited");
});
it("honours the per-IP limiter", async () => {
ipAllowed = false;
expect(await errCode(await post("/v1/feedback", submission()))).toBe("feedback.rate_limited");
});
it("rejects an oversize body and an oversize attachment", async () => {
const big = await post("/v1/feedback", submission({ body: "x".repeat(8193) }));
expect(big.status).toBe(413);
expect(await errCode(big)).toBe("feedback.too_large");
const png = btoa(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) + "a".repeat(2 * 1024 * 1024));
const res = await post("/v1/feedback", submission({ attachments: [{ name: "a.png", contentType: "image/png", dataBase64: png }] }));
expect(await errCode(res)).toBe("feedback.too_large");
});
it("rejects an attachment whose bytes do not match its type", async () => {
const res = await post("/v1/feedback", submission({ attachments: [{ name: "a.png", contentType: "image/png", dataBase64: btoa("not an image") }] }));
expect(res.status).toBe(400);
expect(await errCode(res)).toBe("feedback.invalid");
expect(objects.size).toBe(0);
});
it("stores a valid image and serves it with nosniff", async () => {
const res = await post("/v1/feedback", submission({ attachments: [{ name: "shot.png", contentType: "image/png", dataBase64: PNG_B64 }] }));
expect(res.status).toBe(201);
const [key] = [...objects.keys()];
expect(key.startsWith("feedback/")).toBe(true);
const served = await call(`/v1/feedback/attachments/${key.slice("feedback/".length)}`);
expect(served.status).toBe(200);
expect(served.headers.get("content-type")).toBe("image/png");
expect(served.headers.get("x-content-type-options")).toBe("nosniff");
expect(served.headers.get("content-disposition")).toBe("inline");
expect((await call("/v1/feedback/attachments/zzzzzzzzzzzzzzzzzzzz")).status).toBe(404);
});
it("scrubs secrets from the body server-side", async () => {
await post("/v1/feedback", submission({ body: "my key is sk-abcdefghijklmnopqrstuvwx ok" }));
const list = (await (await call("/v1/admin/feedback/pending", { headers: admin })).json()) as { items: { body: string }[] };
expect(list.items[0].body).not.toContain("sk-abcdefgh");
});
it("stays disabled while either secret is absent", async () => {
env.FEEDBACK_ADMIN_TOKEN = undefined;
expect(await errCode(await post("/v1/feedback", submission()))).toBe("feedback.disabled");
env.FEEDBACK_ADMIN_TOKEN = ADMIN;
env.FEEDBACK_TOKEN_SECRET = undefined;
expect(await errCode(await call("/v1/feedback/mine"))).toBe("feedback.disabled");
});
it("answers feedback.disabled when the kill switch is off", async () => {
env.FEEDBACK_ENABLED = "false";
const res = await post("/v1/feedback", submission());
expect(res.status).toBe(503);
expect(await errCode(res)).toBe("feedback.disabled");
});
});
describe("GET /v1/feedback/mine", () => {
it("rejects a wrong token", async () => {
const res = await call("/v1/feedback/mine", { headers: { "x-install-id": "install-aaaaaaaaaaaaaaaa", "x-install-token": "nope" } });
expect(res.status).toBe(401);
expect(await errCode(res)).toBe("feedback.bad_token");
});
it("lists only the caller's own items", async () => {
const a = (await (await post("/v1/feedback", submission())).json()) as { installToken: string };
const b = (await (await post("/v1/feedback", submission({ installId: "install-bbbbbbbbbbbbbbbb", body: "other" }))).json()) as { installToken: string };
const mine = async (id: string, token: string) =>
((await (await call("/v1/feedback/mine", { headers: { "x-install-id": id, "x-install-token": token } })).json()) as { items: { titleSnippet: string }[] }).items;
expect((await mine("install-aaaaaaaaaaaaaaaa", a.installToken)).map((i) => i.titleSnippet)).toEqual(["the composer freezes"]);
expect((await mine("install-bbbbbbbbbbbbbbbb", b.installToken)).map((i) => i.titleSnippet)).toEqual(["other"]);
});
});
describe("admin flow", () => {
const receiptOf = async (s = submission()) => ((await (await post("/v1/feedback", s)).json()) as { receipt: string }).receipt;
const status = (r: string, body: unknown) => post(`/v1/admin/feedback/${r}/status`, body, admin);
it("requires the admin bearer token", async () => {
expect((await call("/v1/admin/feedback/pending")).status).toBe(401);
expect((await call("/v1/admin/feedback/pending", { headers: { authorization: "Bearer wrong" } })).status).toBe(401);
});
it("walks received -> recorded -> in_progress -> fixed and never contacts leak", async () => {
const r = await receiptOf(submission({ contact: "[email protected]" }));
const pend = await call("/v1/admin/feedback/pending?limit=5", { headers: admin });
const text = await pend.text();
expect(text).toContain(r);
expect(text).not.toContain("example.test");
expect((await post(`/v1/admin/feedback/${r}/recorded`, { issueNumber: 7, issueUrl: "https://github.com/o/r/issues/7" }, admin)).status).toBe(200);
expect((await post(`/v1/admin/feedback/${r}/recorded`, { issueNumber: 7, issueUrl: "https://github.com/o/r/issues/7" }, admin)).status).toBe(200);
expect(((await (await call("/v1/admin/feedback/pending", { headers: admin })).json()) as { items: unknown[] }).items).toHaveLength(0);
expect((await status(r, { status: "in_progress" })).status).toBe(200);
const open = (await (await call("/v1/admin/feedback/open", { headers: admin })).json()) as { items: { receipt: string; issueNumber: number }[] };
expect(open.items).toEqual([expect.objectContaining({ receipt: r, issueNumber: 7 })]);
expect((await status(r, { status: "fixed" })).status).toBe(400);
expect((await status(r, { status: "fixed", resolvedVersion: "next" })).status).toBe(200);
});
it("upgrades fixed(next) to a concrete version once and lists it while awaiting a tag", async () => {
const r = await receiptOf();
await post(`/v1/admin/feedback/${r}/recorded`, { issueNumber: 3, issueUrl: "https://github.com/o/r/issues/3" }, admin);
await status(r, { status: "fixed", resolvedVersion: "next" });
const open = async () => ((await (await call("/v1/admin/feedback/open", { headers: admin })).json()) as { items: { receipt: string }[] }).items;
expect(await open()).toEqual([expect.objectContaining({ receipt: r })]);
expect(await errCode(await status(r, { status: "fixed", resolvedVersion: "latest" }))).toBe("feedback.bad_transition");
expect((await status(r, { status: "fixed", resolvedVersion: "v2.25.0" })).status).toBe(200);
expect(await open()).toHaveLength(0);
expect((await status(r, { status: "fixed", resolvedVersion: "v2.25.0" })).status).toBe(200);
expect(await errCode(await status(r, { status: "fixed", resolvedVersion: "v2.26.0" }))).toBe("feedback.bad_transition");
expect(await errCode(await status(r, { status: "fixed", resolvedVersion: "next" }))).toBe("feedback.bad_transition");
});
it("refuses backward and terminal-to-terminal transitions", async () => {
const r = await receiptOf();
expect(await errCode(await status(r, { status: "in_progress" }))).toBe("feedback.bad_transition");
await post(`/v1/admin/feedback/${r}/recorded`, { issueNumber: 1, issueUrl: "https://github.com/o/r/issues/1" }, admin);
await status(r, { status: "wontfix" });
expect(await errCode(await status(r, { status: "in_progress" }))).toBe("feedback.bad_transition");
expect(await errCode(await status(r, { status: "fixed", resolvedVersion: "v1.0.0" }))).toBe("feedback.bad_transition");
});
it("hides held feedback from the converter until released", async () => {
const links = Array.from({ length: 5 }, (_, i) => `https://spam${i}.test`).join(" ");
const r = await receiptOf(submission({ body: links }));
const items = async () => ((await (await call("/v1/admin/feedback/pending", { headers: admin })).json()) as { items: unknown[] }).items;
expect(await items()).toHaveLength(0);
expect(await errCode(await post(`/v1/admin/feedback/${r}/recorded`, { issueNumber: 2, issueUrl: "https://github.com/o/r/issues/2" }, admin))).toBe("feedback.bad_transition");
expect((await post(`/v1/admin/feedback/${r}/release`, {}, admin)).status).toBe(200);
expect(await items()).toHaveLength(1);
});
});
+120
View File
@@ -0,0 +1,120 @@
import type { Env } from "./env";
import { requireAdmin } from "./feedback_auth";
import { jsonResponse, refuse } from "./feedback_http";
import { pendingItem } from "./feedback_read";
import { RecordedBody, StatusBody } from "./feedback_schema";
import { statusRank, type FeedbackRow, type Status } from "./feedback_types";
const PENDING_DEFAULT = 20;
const PENDING_MAX = 50;
const OPEN_LIMIT = 200;
const CONCRETE_VERSION = /^v\d+\.\d+\.\d+$/;
async function load(env: Env, receipt: string): Promise<FeedbackRow | null> {
return env.DB.prepare("SELECT * FROM feedback WHERE receipt = ?").bind(receipt).first<FeedbackRow>();
}
async function readJson(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
return undefined;
}
}
async function setState(env: Env, receipt: string, from: Status, sets: string, binds: unknown[]): Promise<boolean> {
const res = await env.DB.prepare(`UPDATE feedback SET ${sets}, updated_at = ? WHERE receipt = ? AND status = ?`)
.bind(...binds, new Date().toISOString(), receipt, from)
.run();
return (res.meta?.changes ?? 0) > 0;
}
async function pending(request: Request, env: Env, url: URL): Promise<Response> {
const asked = Number(url.searchParams.get("limit") ?? PENDING_DEFAULT);
const limit = Math.min(PENDING_MAX, Math.max(1, Number.isFinite(asked) ? Math.trunc(asked) : PENDING_DEFAULT));
const { results } = await env.DB.prepare("SELECT * FROM feedback WHERE status = 'received' ORDER BY created_at ASC LIMIT ?")
.bind(limit)
.all<FeedbackRow>();
return jsonResponse({ items: results.map((r) => pendingItem(r, url.origin)) });
}
async function open(env: Env): Promise<Response> {
type Open = Pick<FeedbackRow, "receipt" | "status" | "issue_number" | "issue_url">;
const cols = "SELECT receipt, status, issue_number, issue_url FROM feedback";
const active = await env.DB.prepare(`${cols} WHERE status IN ('recorded','in_progress') ORDER BY created_at ASC LIMIT ?`).bind(OPEN_LIMIT).all<Open>();
const awaitingTag = await env.DB.prepare(`${cols} WHERE status = 'fixed' AND resolved_version = 'next' ORDER BY created_at ASC LIMIT ?`)
.bind(OPEN_LIMIT)
.all<Open>();
const results = [...active.results, ...awaitingTag.results].slice(0, OPEN_LIMIT);
return jsonResponse({
items: results.map((r) => ({ receipt: r.receipt, status: r.status, issueNumber: r.issue_number, issueUrl: r.issue_url })),
});
}
async function recorded(request: Request, env: Env, receipt: string): Promise<Response> {
const body = RecordedBody.safeParse(await readJson(request));
if (!body.success) return refuse("feedback.invalid", "issueNumber and issueUrl are required");
const row = await load(env, receipt);
if (!row) return refuse("feedback.not_found", "unknown receipt");
if (statusRank(row.status) >= 1) return jsonResponse({ receipt, status: row.status });
if (row.status !== "received") return refuse("feedback.bad_transition", "feedback is not releasable to the converter");
const ok = await setState(env, receipt, "received", "status = 'recorded', issue_number = ?, issue_url = ?", [body.data.issueNumber, body.data.issueUrl]);
if (!ok) return refuse("feedback.bad_transition", "status changed concurrently");
return jsonResponse({ receipt, status: "recorded" });
}
async function status(request: Request, env: Env, receipt: string): Promise<Response> {
const body = StatusBody.safeParse(await readJson(request));
if (!body.success) return refuse("feedback.invalid", "unknown or malformed status update");
const { status: next, resolvedVersion, duplicateOf } = body.data;
if (next === "fixed" && !resolvedVersion) return refuse("feedback.invalid", "fixed requires resolvedVersion or \"next\"");
const row = await load(env, receipt);
if (!row) return refuse("feedback.not_found", "unknown receipt");
if (row.status === "fixed" && next === "fixed") {
if (row.resolved_version === resolvedVersion) return jsonResponse({ receipt, status: "fixed" });
if (row.resolved_version !== "next" || !resolvedVersion || !CONCRETE_VERSION.test(resolvedVersion)) {
return refuse("feedback.bad_transition", "a fixed report can only move from \"next\" to a concrete version");
}
if (!(await setState(env, receipt, "fixed", "resolved_version = ?", [resolvedVersion]))) return refuse("feedback.bad_transition", "status changed concurrently");
return jsonResponse({ receipt, status: "fixed" });
}
if (row.status === next) return jsonResponse({ receipt, status: row.status });
if (row.status === "received" || row.status === "held" || statusRank(next) <= statusRank(row.status)) {
return refuse("feedback.bad_transition", "only forward transitions from a recorded report are allowed");
}
const ok = await setState(env, receipt, row.status, "status = ?, resolved_version = ?, duplicate_of = ?", [
next,
next === "fixed" ? (resolvedVersion ?? null) : null,
next === "duplicate" ? (duplicateOf ?? null) : null,
]);
if (!ok) return refuse("feedback.bad_transition", "status changed concurrently");
return jsonResponse({ receipt, status: next });
}
async function release(env: Env, receipt: string): Promise<Response> {
const row = await load(env, receipt);
if (!row) return refuse("feedback.not_found", "unknown receipt");
if (row.status === "received") return jsonResponse({ receipt, status: "received" });
if (row.status !== "held") return refuse("feedback.bad_transition", "only held feedback can be released");
if (!(await setState(env, receipt, "held", "status = 'received'", []))) return refuse("feedback.bad_transition", "status changed concurrently");
return jsonResponse({ receipt, status: "received" });
}
export async function handleAdmin(request: Request, env: Env, url: URL): Promise<Response | null> {
const path = url.pathname;
const method = request.method;
const isPending = path === "/v1/admin/feedback/pending";
const isOpen = path === "/v1/admin/feedback/open";
const m = path.match(/^\/v1\/admin\/feedback\/(FB-[0-9A-Z]{4}-[0-9A-Z]{4})\/(recorded|status|release)$/);
if (!isPending && !isOpen && !m) return null;
const denied = await requireAdmin(request, env);
if (denied) return denied;
if (isPending && method === "GET") return pending(request, env, url);
if (isOpen && method === "GET") return open(env);
if (m && method === "POST") {
if (m[2] === "recorded") return recorded(request, env, m[1]);
if (m[2] === "status") return status(request, env, m[1]);
return release(env, m[1]);
}
return new Response("method not allowed", { status: 405 });
}
@@ -0,0 +1,59 @@
import { newAttachmentKey } from "./feedback_crypto";
import { MAX_ATTACHMENT_BYTES, type StoredAttachment } from "./feedback_types";
export const ATTACHMENT_PREFIX = "feedback/";
export const ATTACHMENT_ROUTE = "/v1/feedback/attachments/";
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG = [0xff, 0xd8, 0xff];
const MAGIC: Record<string, number[]> = { "image/png": PNG, "image/jpeg": JPEG };
export interface AttachmentInput {
name: string;
contentType: string;
dataBase64: string;
}
export type DecodedAttachment = { ok: true; name: string; contentType: string; bytes: Uint8Array } | { ok: false; reason: "too_large" | "invalid" };
export function decodeAttachment(a: AttachmentInput): DecodedAttachment {
const magic = MAGIC[a.contentType];
if (!magic) return { ok: false, reason: "invalid" };
if (a.dataBase64.length > Math.ceil((MAX_ATTACHMENT_BYTES * 4) / 3) + 4) return { ok: false, reason: "too_large" };
let bytes: Uint8Array;
try {
const bin = atob(a.dataBase64);
bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
} catch {
return { ok: false, reason: "invalid" };
}
if (bytes.length > MAX_ATTACHMENT_BYTES) return { ok: false, reason: "too_large" };
if (bytes.length < magic.length || !magic.every((m, i) => bytes[i] === m)) return { ok: false, reason: "invalid" };
const name = a.name.replace(/[^\w.\- ]/g, "_").slice(0, 80) || "image";
return { ok: true, name, contentType: a.contentType, bytes };
}
export async function storeAttachment(r2: R2Bucket, d: Extract<DecodedAttachment, { ok: true }>): Promise<StoredAttachment> {
const key = newAttachmentKey();
await r2.put(ATTACHMENT_PREFIX + key, d.bytes, { httpMetadata: { contentType: d.contentType } });
return { key, name: d.name, contentType: d.contentType, size: d.bytes.length };
}
export function attachmentUrl(origin: string, key: string): string {
return `${origin}${ATTACHMENT_ROUTE}${key}`;
}
export async function serveAttachment(r2: R2Bucket | undefined, key: string): Promise<Response> {
if (!r2 || !/^[A-Za-z0-9_-]{16,64}$/.test(key)) return new Response("not found", { status: 404 });
const obj = await r2.get(ATTACHMENT_PREFIX + key);
const type = obj?.httpMetadata?.contentType;
if (!obj || !type || !MAGIC[type]) return new Response("not found", { status: 404 });
return new Response(obj.body, {
headers: {
"content-type": type,
"x-content-type-options": "nosniff",
"content-disposition": "inline",
"cache-control": "public, max-age=31536000, immutable",
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import type { Env } from "./env";
import { constantTimeEqual, installToken, sha256Hex } from "./feedback_crypto";
import { refuse } from "./feedback_http";
// Endpoints stay off until both secrets exist, so a half-configured deploy never
// mints tokens the converter cannot be authorised against.
export function feedbackEnabled(env: Env): boolean {
return env.FEEDBACK_ENABLED !== "false" && !!env.FEEDBACK_TOKEN_SECRET && !!env.FEEDBACK_ADMIN_TOKEN;
}
export async function verifyInstall(request: Request, env: Env): Promise<{ installHash: string } | Response> {
if (!feedbackEnabled(env)) return refuse("feedback.disabled", "feedback is unavailable");
const id = request.headers.get("x-install-id") ?? "";
const token = request.headers.get("x-install-token") ?? "";
if (!env.FEEDBACK_TOKEN_SECRET || !/^[A-Za-z0-9_-]{16,64}$/.test(id) || !token) {
return refuse("feedback.bad_token", "install token missing or invalid");
}
if (!(await constantTimeEqual(token, await installToken(env.FEEDBACK_TOKEN_SECRET, id)))) {
return refuse("feedback.bad_token", "install token missing or invalid");
}
return { installHash: await sha256Hex(id) };
}
export async function requireAdmin(request: Request, env: Env): Promise<Response | null> {
const header = request.headers.get("authorization") ?? "";
const presented = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!env.FEEDBACK_ADMIN_TOKEN || !presented || !(await constantTimeEqual(presented, env.FEEDBACK_ADMIN_TOKEN))) {
return refuse("feedback.unauthorized", "admin token missing or invalid");
}
return null;
}
@@ -0,0 +1,41 @@
const enc = new TextEncoder();
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
export function base64url(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function hex(bytes: Uint8Array): string {
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function sha256Hex(input: string): Promise<string> {
return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(input))));
}
export async function installToken(secret: string, installId: string): Promise<string> {
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(installId))));
}
// Compares fixed-length digests so timing does not depend on where inputs differ.
export async function constantTimeEqual(a: string, b: string): Promise<boolean> {
const [da, db] = await Promise.all([crypto.subtle.digest("SHA-256", enc.encode(a)), crypto.subtle.digest("SHA-256", enc.encode(b))]);
const x = new Uint8Array(da);
const y = new Uint8Array(db);
let diff = 0;
for (let i = 0; i < x.length; i++) diff |= x[i] ^ y[i];
return diff === 0;
}
export function newReceipt(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8));
const chars = [...bytes].map((b) => CROCKFORD[b & 31]).join("");
return `FB-${chars.slice(0, 4)}-${chars.slice(4)}`;
}
export function newAttachmentKey(): string {
return base64url(crypto.getRandomValues(new Uint8Array(24)));
}
+33
View File
@@ -0,0 +1,33 @@
export type FeedbackCode =
| "feedback.too_large"
| "feedback.rate_limited"
| "feedback.invalid"
| "feedback.disabled"
| "feedback.duplicate"
| "feedback.bad_token"
| "feedback.unauthorized"
| "feedback.not_found"
| "feedback.bad_transition";
const STATUS: Record<FeedbackCode, number> = {
"feedback.too_large": 413,
"feedback.rate_limited": 429,
"feedback.invalid": 400,
"feedback.disabled": 503,
"feedback.duplicate": 409,
"feedback.bad_token": 401,
"feedback.unauthorized": 401,
"feedback.not_found": 404,
"feedback.bad_transition": 409,
};
export function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers },
});
}
export function refuse(code: FeedbackCode, message: string): Response {
return jsonResponse({ error: { code, message } }, STATUS[code]);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Env } from "./env";
import { attachmentUrl } from "./feedback_attachments";
import { verifyInstall } from "./feedback_auth";
import { jsonResponse } from "./feedback_http";
import type { FeedbackRow, StoredAttachment } from "./feedback_types";
const MINE_LIMIT = 50;
const SNIPPET_CHARS = 80;
function publicStatus(row: FeedbackRow) {
return row.status === "held" ? "received" : row.status;
}
export async function handleMine(request: Request, env: Env): Promise<Response> {
const who = await verifyInstall(request, env);
if (who instanceof Response) return who;
const { results } = await env.DB.prepare("SELECT * FROM feedback WHERE install_hash = ? ORDER BY created_at DESC LIMIT ?")
.bind(who.installHash, MINE_LIMIT)
.all<FeedbackRow>();
return jsonResponse({
items: results.map((r) => ({
receipt: r.receipt,
category: r.category,
titleSnippet: [...r.body].slice(0, SNIPPET_CHARS).join(""),
status: publicStatus(r),
issueNumber: r.issue_number,
issueUrl: r.issue_url,
resolvedVersion: r.resolved_version,
duplicateOf: r.duplicate_of,
createdAt: r.created_at,
updatedAt: r.updated_at,
})),
});
}
export function pendingItem(row: FeedbackRow, origin: string) {
const attachments = JSON.parse(row.attachments_json) as StoredAttachment[];
return {
receipt: row.receipt,
category: row.category,
body: row.body,
displayName: row.display_name,
env: JSON.parse(row.env_json) as Record<string, string>,
attachments: attachments.map((a) => ({ name: a.name, contentType: a.contentType, url: attachmentUrl(origin, a.key) })),
createdAt: row.created_at,
};
}
@@ -0,0 +1,18 @@
import type { Env } from "./env";
import { handleAdmin } from "./feedback_admin";
import { ATTACHMENT_ROUTE, serveAttachment } from "./feedback_attachments";
import { handleMine } from "./feedback_read";
import { handleSubmit } from "./feedback_submit";
// Returns null when the path is not a feedback route so the caller keeps routing.
export async function handleFeedbackRoute(request: Request, env: Env): Promise<Response | null> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
if (path === "/v1/feedback") return method === "POST" ? handleSubmit(request, env) : new Response("method not allowed", { status: 405 });
if (path === "/v1/feedback/mine") return method === "GET" ? handleMine(request, env) : new Response("method not allowed", { status: 405 });
if (path.startsWith(ATTACHMENT_ROUTE)) {
return method === "GET" ? serveAttachment(env.FEEDBACK_R2, path.slice(ATTACHMENT_ROUTE.length)) : new Response("method not allowed", { status: 405 });
}
return handleAdmin(request, env, url);
}
@@ -0,0 +1,39 @@
import { z } from "zod";
import { CATEGORIES } from "./feedback_types";
const short = (n: number) => z.string().max(n).optional();
export const FeedbackEnv = z.object({
version: short(40),
commit: short(40),
surface: z.enum(["studio", "tui", "acp"]).optional(),
os: short(40),
osVersion: short(80),
arch: short(20),
locale: short(20),
channel: z.enum(["stable", "preview"]).optional(),
providerKind: z.enum(["deepseek", "openai", "anthropic", "other"]).optional(),
});
export const FeedbackSubmit = z.object({
idempotencyKey: z.string().min(8).max(64),
installId: z.string().regex(/^[A-Za-z0-9_-]{16,64}$/),
category: z.enum(CATEGORIES),
body: z.string().refine((s) => s.trim().length > 0),
displayName: z.string().trim().min(1).max(40),
contact: z.string().max(120).optional(),
env: FeedbackEnv.default({}),
attachments: z
.array(z.object({ name: z.string().max(200), contentType: z.string().max(40), dataBase64: z.string() }))
.max(3)
.default([]),
});
export type FeedbackSubmitInput = z.infer<typeof FeedbackSubmit>;
export const RecordedBody = z.object({ issueNumber: z.number().int().positive(), issueUrl: z.string().url().max(300) });
export const StatusBody = z.object({
status: z.enum(["in_progress", "fixed", "wontfix", "duplicate"]),
resolvedVersion: z.string().max(40).optional(),
duplicateOf: z.string().max(40).optional(),
});
+144
View File
@@ -0,0 +1,144 @@
import type { Env } from "./env";
import { decodeAttachment, storeAttachment } from "./feedback_attachments";
import { feedbackEnabled } from "./feedback_auth";
import { installToken, newReceipt, sha256Hex } from "./feedback_crypto";
import { jsonResponse, refuse } from "./feedback_http";
import { FeedbackSubmit, type FeedbackSubmitInput } from "./feedback_schema";
import {
MAX_ATTACHMENTS,
MAX_BODY_BYTES,
MAX_REQUEST_BYTES,
PER_INSTALL_DAILY,
PER_INSTALL_HOURLY,
type FeedbackRow,
type StoredAttachment,
} from "./feedback_types";
import { scrubSensitiveText } from "./scrub";
const RECEIPT_ATTEMPTS = 5;
const MAX_LINKS_BEFORE_HOLD = 3;
function scrubEnv(env: FeedbackSubmitInput["env"]): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) if (typeof v === "string") out[k] = scrubSensitiveText(v);
return out;
}
// A body that is mostly links is the one structural spam signal; a person
// describing a bug rarely pastes more than a few.
function tripsSpamGate(body: string): boolean {
return (body.match(/https?:\/\//gi)?.length ?? 0) > MAX_LINKS_BEFORE_HOLD;
}
async function findByKey(env: Env, installHash: string, key: string): Promise<FeedbackRow | null> {
return env.DB.prepare("SELECT * FROM feedback WHERE install_hash = ? AND idempotency_key = ?")
.bind(installHash, key)
.first<FeedbackRow>();
}
async function overInstallLimit(env: Env, installHash: string, now: number): Promise<boolean> {
const dayAgo = new Date(now - 86_400_000).toISOString();
const hourAgo = new Date(now - 3_600_000).toISOString();
const { results } = await env.DB.prepare(
"SELECT created_at FROM feedback WHERE install_hash = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?",
)
.bind(installHash, dayAgo, PER_INSTALL_DAILY)
.all<{ created_at: string }>();
return results.length >= PER_INSTALL_DAILY || results.filter((r) => r.created_at >= hourAgo).length >= PER_INSTALL_HOURLY;
}
function receiptBody(row: FeedbackRow, token: string) {
return {
receipt: row.receipt,
status: row.status === "held" ? "received" : row.status,
installToken: token,
createdAt: row.created_at,
};
}
async function insertWithReceipt(env: Env, row: Omit<FeedbackRow, "receipt">, key: string): Promise<FeedbackRow | { replay: FeedbackRow } | null> {
for (let i = 0; i < RECEIPT_ATTEMPTS; i++) {
const receipt = newReceipt();
try {
await env.DB.prepare(
`INSERT INTO feedback (receipt, install_hash, idempotency_key, category, body, display_name, contact, env_json,
attachments_json, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
)
.bind(receipt, row.install_hash, key, row.category, row.body, row.display_name, row.contact, row.env_json, row.attachments_json, row.status, row.created_at, row.updated_at)
.run();
return { ...row, receipt };
} catch (err) {
const existing = await findByKey(env, row.install_hash, key);
if (existing) return { replay: existing };
if (i === RECEIPT_ATTEMPTS - 1) throw err;
}
}
return null;
}
export async function handleSubmit(request: Request, env: Env): Promise<Response> {
if (!feedbackEnabled(env) || !env.FEEDBACK_TOKEN_SECRET) return refuse("feedback.disabled", "feedback is unavailable");
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
if (env.FEEDBACK_LIMITER && !(await env.FEEDBACK_LIMITER.limit({ key: ip })).success) return refuse("feedback.rate_limited", "too many submissions");
if (env.FEEDBACK_BUDGET_LIMITER && !(await env.FEEDBACK_BUDGET_LIMITER.limit({ key: "global" })).success) {
return refuse("feedback.rate_limited", "feedback is busy, try again later");
}
const declared = Number(request.headers.get("content-length") ?? 0);
if (declared > MAX_REQUEST_BYTES) return refuse("feedback.too_large", "request too large");
const text = await request.text();
if (text.length > MAX_REQUEST_BYTES) return refuse("feedback.too_large", "request too large");
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return refuse("feedback.invalid", "body is not valid JSON");
}
const parsed = FeedbackSubmit.safeParse(raw);
if (!parsed.success) return refuse("feedback.invalid", "request does not match the feedback schema");
const input = parsed.data;
if (new TextEncoder().encode(input.body).length > MAX_BODY_BYTES) return refuse("feedback.too_large", "body exceeds 8192 bytes");
if (input.attachments.length > MAX_ATTACHMENTS) return refuse("feedback.invalid", "too many attachments");
const installHash = await sha256Hex(input.installId);
const token = await installToken(env.FEEDBACK_TOKEN_SECRET, input.installId);
const replay = await findByKey(env, installHash, input.idempotencyKey);
if (replay) return jsonResponse(receiptBody(replay, token), 200);
const decoded = input.attachments.map(decodeAttachment);
for (const d of decoded) {
if (!d.ok) return refuse(d.reason === "too_large" ? "feedback.too_large" : "feedback.invalid", "attachment rejected");
}
if (decoded.length > 0 && !env.FEEDBACK_R2) return refuse("feedback.disabled", "attachments are unavailable");
const now = Date.now();
if (await overInstallLimit(env, installHash, now)) return refuse("feedback.rate_limited", "submission limit reached for this install");
const stored: StoredAttachment[] = [];
for (const d of decoded) if (d.ok && env.FEEDBACK_R2) stored.push(await storeAttachment(env.FEEDBACK_R2, d));
const body = scrubSensitiveText(input.body);
const at = new Date(now).toISOString();
const outcome = await insertWithReceipt(
env,
{
install_hash: installHash,
category: input.category,
body,
display_name: scrubSensitiveText(input.displayName),
contact: input.contact ?? "",
env_json: JSON.stringify(scrubEnv(input.env)),
attachments_json: JSON.stringify(stored),
status: tripsSpamGate(body) ? "held" : "received",
issue_number: null,
issue_url: null,
resolved_version: null,
duplicate_of: null,
created_at: at,
updated_at: at,
},
input.idempotencyKey,
);
if (!outcome) return refuse("feedback.disabled", "could not allocate a receipt");
if ("replay" in outcome) return jsonResponse(receiptBody(outcome.replay, token), 200);
return jsonResponse(receiptBody(outcome, token), 201);
}
@@ -0,0 +1,53 @@
export const CATEGORIES = ["bug", "idea", "question", "other"] as const;
export type Category = (typeof CATEGORIES)[number];
export const STATUSES = ["received", "held", "recorded", "in_progress", "fixed", "wontfix", "duplicate"] as const;
export type Status = (typeof STATUSES)[number];
export const MAX_BODY_BYTES = 8192;
export const MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024;
export const MAX_ATTACHMENTS = 3;
export const MAX_REQUEST_BYTES = 8 * 1024 * 1024;
export const PER_INSTALL_HOURLY = 3;
export const PER_INSTALL_DAILY = 10;
export interface StoredAttachment {
key: string;
name: string;
contentType: string;
size: number;
}
export interface FeedbackRow {
receipt: string;
install_hash: string;
category: Category;
body: string;
display_name: string;
contact: string;
env_json: string;
attachments_json: string;
status: Status;
issue_number: number | null;
issue_url: string | null;
resolved_version: string | null;
duplicate_of: string | null;
created_at: string;
updated_at: string;
}
// Rank orders the forward-only lifecycle; the three outcomes share the top rank
// so none of them can move to another.
export function statusRank(s: Status): number {
switch (s) {
case "held":
case "received":
return 0;
case "recorded":
return 1;
case "in_progress":
return 2;
default:
return 3;
}
}
+7 -17
View File
@@ -24,6 +24,8 @@ import type { Bindings as RegistryBindings } from "./registry/env";
import { PackageRepo } from "./registry/db/packages";
import { EventRepo } from "./registry/db/events";
import { renderCommunity } from "./community";
import { scrubSensitiveText } from "./scrub";
import { handleFeedbackRoute } from "./feedback_routes";
import {
cliReleaseChannel,
desktopReleaseChannel,
@@ -280,23 +282,6 @@ type FingerprintInput = {
fingerprintHint?: string;
};
export function scrubSensitiveText(input: string): string {
return input
.replace(/([A-Z]:\\Users\\)[^/\\:\s"']+/gi, "$1_")
.replace(/(\/(?:home|Users)\/)[^/\\:\s"']+/g, "$1_")
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[redacted-email]")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [redacted]")
.replace(
/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?/gi,
"$1=[redacted]",
)
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted-jwt]")
.replace(/\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted-key]")
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[redacted-hex]")
.replace(/[A-Za-z0-9+/]{40,}={0,2}/g, "[redacted-token]")
.replace(/\b[A-Za-z0-9_-]{48,}\b/g, "[redacted-token]");
}
function normalizeStackFrame(frame: string): string {
return frame
.replace(/[A-Za-z]:\\[^\s)('"]+/g, "<path>")
@@ -1388,6 +1373,8 @@ async function purgeExpiredStatsRows(env: Env): Promise<void> {
}
}
export { scrubSensitiveText };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
@@ -1403,6 +1390,9 @@ export default {
return handleReleaseGatewayRequest(method, () => handleCLIRelease(cliRelease));
}
const feedback = await handleFeedbackRoute(request, env);
if (feedback) return feedback;
if (path === "/v1/report" && method === "POST") return handleReport(request, env);
if (path === "/v1/ping" && method === "POST") return handlePing(request, env);
if (path === "/v1/metrics" && method === "POST") return handleMetrics(request, env);
+16
View File
@@ -0,0 +1,16 @@
export function scrubSensitiveText(input: string): string {
return input
.replace(/([A-Z]:\\Users\\)[^/\\:\s"']+/gi, "$1_")
.replace(/(\/(?:home|Users)\/)[^/\\:\s"']+/g, "$1_")
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[redacted-email]")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [redacted]")
.replace(
/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?/gi,
"$1=[redacted]",
)
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted-jwt]")
.replace(/\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted-key]")
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[redacted-hex]")
.replace(/[A-Za-z0-9+/]{40,}={0,2}/g, "[redacted-token]")
.replace(/\b[A-Za-z0-9_-]{48,}\b/g, "[redacted-token]");
}
+26
View File
@@ -27,6 +27,11 @@ ID_ORIGIN = "https://id.reasonix.io"
APP_ORIGIN = "https://reasonix.io"
# Browsers allowed to call the folded registry API with credentials.
ALLOWED_ORIGINS = "https://reasonix.io,https://www.reasonix.io"
# Kill switch for in-app feedback submissions: "false" answers feedback.disabled.
# Secrets (never in this file; set with `wrangler secret put`):
# FEEDBACK_TOKEN_SECRET signs per-install read tokens
# FEEDBACK_ADMIN_TOKEN bearer for the converter's /v1/admin/feedback endpoints
FEEDBACK_ENABLED = "true"
[[d1_databases]]
binding = "DB"
@@ -69,3 +74,24 @@ name = "WRITE_LIMITER"
type = "ratelimit"
namespace_id = "3001"
simple = { limit = 30, period = 60 }
# Per-IP throttle for feedback submissions; the per-install hour/day caps live in
# the handler.
[[unsafe.bindings]]
name = "FEEDBACK_LIMITER"
type = "ratelimit"
namespace_id = "4001"
simple = { limit = 6, period = 60 }
# Global admission ceiling for feedback, independent of IP.
[[unsafe.bindings]]
name = "FEEDBACK_BUDGET_LIMITER"
type = "ratelimit"
namespace_id = "4002"
simple = { limit = 60, period = 60 }
# Feedback screenshots live under the feedback/ prefix. Create the bucket before
# deploying: wrangler r2 bucket create reasonix-telemetry-raw
[[r2_buckets]]
binding = "FEEDBACK_R2"
bucket_name = "reasonix-telemetry-raw"