mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7943fac193 | ||
|
|
a6cf72080f |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user