diff --git a/workers/crash-report/migrate.sql b/workers/crash-report/migrate.sql
new file mode 100644
index 0000000000..fde0af7381
--- /dev/null
+++ b/workers/crash-report/migrate.sql
@@ -0,0 +1,34 @@
+-- One-time upgrade of the live reasonix-crash DB to the account system.
+-- Apply once: wrangler d1 execute reasonix-crash --remote --file=migrate.sql
+-- The ALTERs are not idempotent; the CREATEs are. Fresh installs use schema.sql.
+ALTER TABLE groups ADD COLUMN status TEXT NOT NULL DEFAULT 'open';
+ALTER TABLE groups ADD COLUMN note TEXT NOT NULL DEFAULT '';
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT NOT NULL,
+ approved_at TEXT,
+ approved_by INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id);
+
+CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ at TEXT NOT NULL,
+ actor_id INTEGER,
+ actor_email TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target TEXT NOT NULL DEFAULT '',
+ detail TEXT NOT NULL DEFAULT ''
+);
diff --git a/workers/crash-report/schema.sql b/workers/crash-report/schema.sql
index 37b4afac27..9444d61021 100644
--- a/workers/crash-report/schema.sql
+++ b/workers/crash-report/schema.sql
@@ -5,7 +5,9 @@ CREATE TABLE IF NOT EXISTS groups (
count INTEGER NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
- last_version TEXT NOT NULL
+ last_version TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open',
+ note TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS reports (
@@ -44,3 +46,32 @@ CREATE TABLE IF NOT EXISTS metrics (
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, version, os, signal, bucket)
);
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT NOT NULL,
+ approved_at TEXT,
+ approved_by INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id);
+
+CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ at TEXT NOT NULL,
+ actor_id INTEGER,
+ actor_email TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target TEXT NOT NULL DEFAULT '',
+ detail TEXT NOT NULL DEFAULT ''
+);
diff --git a/workers/crash-report/src/admin.ts b/workers/crash-report/src/admin.ts
new file mode 100644
index 0000000000..b390f706c1
--- /dev/null
+++ b/workers/crash-report/src/admin.ts
@@ -0,0 +1,75 @@
+import { esc, page } from "./shell";
+import { type Role, type User, userNav } from "./auth";
+
+export interface UserRow {
+ id: number;
+ email: string;
+ role: Role;
+ created_at: string;
+ approved_at: string | null;
+}
+
+export interface AuditRow {
+ at: string;
+ actor_email: string;
+ action: string;
+ target: string;
+ detail: string;
+}
+
+function roleSelect(row: UserRow): string {
+ const opt = (r: Role) => `${r} `;
+ return `
`;
+}
+
+function deleteForm(row: UserRow): string {
+ return ``;
+}
+
+export function renderUsers(viewer: User, users: UserRow[], n?: { kind: "err" | "ok"; text: string }): string {
+ const pending = users.filter((u) => u.role === "pending").length;
+ const rows = users
+ .map((u) => {
+ const self = u.id === viewer.id;
+ const approved = u.approved_at ? esc(u.approved_at.slice(0, 10)) : "—";
+ return `${esc(u.email)}${self ? ' you ' : ""}
+${self ? `${u.role} ` : roleSelect(u)}
+${esc(u.created_at.slice(0, 10))} ${approved}
+${self ? "" : deleteForm(u)} `;
+ })
+ .join("");
+ return page(
+ "Reasonix · Users",
+ "admin / users",
+ `Users ${users.length} accounts · ${pending} awaiting approval · set a role to grant or revoke access
+${n ? `${esc(n.text)}
` : ""}
+email role joined approved
+${rows}
+View audit log → `,
+ userNav(viewer),
+ );
+}
+
+export function renderAudit(viewer: User, rows: AuditRow[]): string {
+ const body = rows.length
+ ? `when actor action target detail ${rows
+ .map(
+ (r) =>
+ `${esc(r.at.slice(0, 19).replace("T", " "))} ${esc(r.actor_email)} ${esc(r.action)} ${esc(r.target)} ${esc(r.detail)} `,
+ )
+ .join("")}
`
+ : `No actions logged yet
`;
+ return page(
+ "Reasonix · Audit",
+ "admin / audit",
+ `Audit log Permission and crash-data changes, newest first
+${body}
+← Back to users `,
+ userNav(viewer),
+ );
+}
diff --git a/workers/crash-report/src/auth.ts b/workers/crash-report/src/auth.ts
new file mode 100644
index 0000000000..4f93d3855c
--- /dev/null
+++ b/workers/crash-report/src/auth.ts
@@ -0,0 +1,145 @@
+import type { Env } from "./env";
+import { esc } from "./shell";
+
+export type Role = "pending" | "viewer" | "admin";
+
+export interface User {
+ id: number;
+ email: string;
+ role: Role;
+ created_at: string;
+ approved_at: string | null;
+}
+
+const RANK: Record = { pending: 0, viewer: 1, admin: 2 };
+
+export function atLeast(role: Role, min: Role): boolean {
+ return RANK[role] >= RANK[min];
+}
+
+const COOKIE = "rxsess";
+const SESSION_TTL_MS = 30 * 86_400_000;
+const PBKDF2_ITERS = 100_000;
+
+function b64(bytes: Uint8Array): string {
+ let s = "";
+ for (const byte of bytes) s += String.fromCharCode(byte);
+ return btoa(s);
+}
+
+function unb64(s: string): Uint8Array {
+ const bin = atob(s);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise {
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, key, 256);
+ return new Uint8Array(bits);
+}
+
+export async function hashPassword(password: string): Promise {
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const hash = await deriveBits(password, salt, PBKDF2_ITERS);
+ return `pbkdf2$${PBKDF2_ITERS}$${b64(salt)}$${b64(hash)}`;
+}
+
+export async function verifyPassword(password: string, stored: string): Promise {
+ const [scheme, iters, saltB64, hashB64] = stored.split("$");
+ if (scheme !== "pbkdf2") return false;
+ const got = await deriveBits(password, unb64(saltB64), Number(iters));
+ const want = unb64(hashB64);
+ return got.byteLength === want.byteLength && crypto.subtle.timingSafeEqual(got, want);
+}
+
+function randomToken(): string {
+ const b = crypto.getRandomValues(new Uint8Array(32));
+ return [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
+}
+
+export async function createSession(env: Env, userId: number): Promise {
+ const token = randomToken();
+ const now = Date.now();
+ await env.DB.prepare("INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)")
+ .bind(token, userId, new Date(now).toISOString(), new Date(now + SESSION_TTL_MS).toISOString())
+ .run();
+ return token;
+}
+
+export async function destroySession(env: Env, token: string): Promise {
+ await env.DB.prepare("DELETE FROM sessions WHERE token = ?1").bind(token).run();
+}
+
+export async function endSession(request: Request, env: Env): Promise {
+ const token = getCookie(request, COOKIE);
+ if (token) await destroySession(env, token);
+}
+
+export function sessionCookie(token: string): string {
+ return `${COOKIE}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
+}
+
+export function clearCookie(): string {
+ return `${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
+}
+
+export function getCookie(request: Request, name: string): string | null {
+ const header = request.headers.get("cookie") ?? "";
+ for (const part of header.split(";")) {
+ const [k, ...v] = part.trim().split("=");
+ if (k === name) return v.join("=");
+ }
+ return null;
+}
+
+export async function currentUser(request: Request, env: Env): Promise {
+ const token = getCookie(request, COOKIE);
+ if (!token) return null;
+ const row = await env.DB.prepare(
+ `SELECT u.id, u.email, u.role, u.created_at, u.approved_at, s.expires_at
+ FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?1`,
+ )
+ .bind(token)
+ .first<{ id: number; email: string; role: Role; created_at: string; approved_at: string | null; expires_at: string }>();
+ if (!row) return null;
+ if (row.expires_at < new Date().toISOString()) {
+ await destroySession(env, token);
+ return null;
+ }
+ return { id: row.id, email: row.email, role: row.role, created_at: row.created_at, approved_at: row.approved_at };
+}
+
+export function isAdminEmail(env: Env, email: string): boolean {
+ const list = (env.ADMIN_EMAILS ?? "")
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+ return list.includes(email.toLowerCase());
+}
+
+// CSRF guard for cookie-authed POSTs: a missing Origin is rejected, not waved
+// through as same-site.
+export function sameOrigin(request: Request): boolean {
+ const origin = request.headers.get("origin");
+ if (!origin) return false;
+ try {
+ return new URL(origin).host === new URL(request.url).host;
+ } catch {
+ return false;
+ }
+}
+
+export async function logAction(env: Env, actor: User, action: string, target = "", detail = ""): Promise {
+ await env.DB.prepare(
+ "INSERT INTO audit_log (at, actor_id, actor_email, action, target, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ )
+ .bind(new Date().toISOString(), actor.id, actor.email, action, target, detail)
+ .run();
+}
+
+export function userNav(user: User): string {
+ const admin = user.role === "admin" ? `Admin ` : "";
+ return `${user.role} ${esc(user.email)}Account ${admin}`;
+}
diff --git a/workers/crash-report/src/auth_pages.ts b/workers/crash-report/src/auth_pages.ts
new file mode 100644
index 0000000000..4a6302ceae
--- /dev/null
+++ b/workers/crash-report/src/auth_pages.ts
@@ -0,0 +1,58 @@
+import { esc, page } from "./shell";
+import { type User, userNav } from "./auth";
+
+function notice(n?: { kind: "err" | "ok"; text: string }): string {
+ return n ? `${esc(n.text)}
` : "";
+}
+
+function authShell(title: string, crumb: string, body: string): string {
+ return page(title, crumb, ``);
+}
+
+export function renderLogin(n?: { kind: "err" | "ok"; text: string }): string {
+ return authShell(
+ "Reasonix · Sign in",
+ "sign in",
+ `Sign in Crash & telemetry dashboard
${notice(n)}
+
+`,
+ );
+}
+
+export function renderRegister(n?: { kind: "err" | "ok"; text: string }): string {
+ return authShell(
+ "Reasonix · Register",
+ "register",
+ `Create account New accounts start with no access until an admin approves them.
${notice(n)}
+
+`,
+ );
+}
+
+export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string }): string {
+ const pending = user.role === "pending";
+ const status = pending
+ ? `Your account is awaiting approval. An admin needs to grant you access before the crash dashboard becomes visible.
`
+ : ``;
+ return page(
+ "Reasonix · Account",
+ "account",
+ `Account ${esc(user.email)} · joined ${esc(user.created_at.slice(0, 10))}
+${notice(n)}${status}
+`,
+ userNav(user),
+ );
+}
diff --git a/workers/crash-report/src/env.ts b/workers/crash-report/src/env.ts
new file mode 100644
index 0000000000..9a86a1b174
--- /dev/null
+++ b/workers/crash-report/src/env.ts
@@ -0,0 +1,11 @@
+export interface RateLimiter {
+ limit(opts: { key: string }): Promise<{ success: boolean }>;
+}
+
+export interface Env {
+ DB: D1Database;
+ RATE_LIMITER: RateLimiter;
+ PING_LIMITER: RateLimiter;
+ METRICS_LIMITER: RateLimiter;
+ ADMIN_EMAILS?: string;
+}
diff --git a/workers/crash-report/src/index.ts b/workers/crash-report/src/index.ts
index 72ee80706a..2214499891 100644
--- a/workers/crash-report/src/index.ts
+++ b/workers/crash-report/src/index.ts
@@ -1,19 +1,26 @@
-// Ingest + stats for desktop crash/feedback reports and the anonymous launch
+// Ingest + dashboard for desktop crash/feedback reports and the anonymous launch
// ping. Reports are user-initiated; pings are opt-out (desktop.telemetry).
import { z } from "zod";
-import { renderGroup, renderStats } from "./stats";
-
-interface RateLimiter {
- limit(opts: { key: string }): Promise<{ success: boolean }>;
-}
-
-interface Env {
- DB: D1Database;
- RATE_LIMITER: RateLimiter;
- PING_LIMITER: RateLimiter;
- METRICS_LIMITER: RateLimiter;
- STATS_PASSWORD?: string;
-}
+import type { Env } from "./env";
+import { html, redirect } from "./shell";
+import { renderGroup, renderStats, type Group } from "./stats";
+import { renderLogin, renderRegister, renderAccount } from "./auth_pages";
+import { renderUsers, renderAudit, type UserRow, type AuditRow } from "./admin";
+import {
+ atLeast,
+ createSession,
+ currentUser,
+ endSession,
+ hashPassword,
+ isAdminEmail,
+ logAction,
+ sameOrigin,
+ sessionCookie,
+ clearCookie,
+ verifyPassword,
+ type Role,
+ type User,
+} from "./auth";
const MAX_BODY_BYTES = 32 * 1024;
const SAMPLES_PER_GROUP = 5;
@@ -187,29 +194,92 @@ async function handleMetrics(request: Request, env: Env): Promise {
return new Response("ok", { status: 202 });
}
-function statsAuthorized(request: Request, env: Env): boolean {
- // trim: secrets piped in via PowerShell arrive with a trailing newline.
- const want = (env.STATS_PASSWORD ?? "").trim();
- if (!want) return false;
- const header = request.headers.get("authorization") ?? "";
- if (!header.startsWith("Basic ")) return false;
- let pass: string;
- try {
- pass = atob(header.slice(6)).split(":").slice(1).join(":");
- } catch {
- return false;
- }
- const enc = new TextEncoder();
- const a = enc.encode(pass);
- const b = enc.encode(want);
- return a.byteLength === b.byteLength && crypto.subtle.timingSafeEqual(a, b);
+const Credentials = z.object({
+ email: z.string().email().max(254),
+ password: z.string().min(8).max(200),
+});
+
+const PasswordChange = z.object({
+ current: z.string().min(1).max(200),
+ next: z.string().min(8).max(200),
+});
+
+const UserAction = z.object({
+ action: z.enum(["role", "delete"]),
+ userId: z.coerce.number().int().positive(),
+ role: z.enum(["pending", "viewer", "admin"]).optional(),
+});
+
+const GroupAction = z.object({
+ action: z.enum(["status", "delete", "note"]),
+ status: z.enum(["open", "resolved", "ignored"]).optional(),
+ note: z.string().max(500).optional(),
+});
+
+async function formObject(request: Request): Promise> {
+ const form = await request.formData();
+ const out: Record = {};
+ for (const [k, v] of form) out[k] = typeof v === "string" ? v : "";
+ return out;
}
-function html(body: string): Response {
- return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
+async function handleRegister(request: Request, env: Env): Promise {
+ if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
+ const parsed = Credentials.safeParse(await formObject(request));
+ if (!parsed.success)
+ return html(renderRegister({ kind: "err", text: "Enter a valid email and a password of at least 8 characters." }));
+ const email = parsed.data.email.toLowerCase();
+
+ const existing = await env.DB.prepare("SELECT id FROM users WHERE email = ?1").bind(email).first();
+ if (existing) return html(renderRegister({ kind: "err", text: "That email is already registered — try signing in." }));
+
+ const role: Role = isAdminEmail(env, email) ? "admin" : "pending";
+ const now = new Date().toISOString();
+ const hash = await hashPassword(parsed.data.password);
+ const res = await env.DB.prepare(
+ "INSERT INTO users (email, password_hash, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4, ?5)",
+ )
+ .bind(email, hash, role, now, role === "admin" ? now : null)
+ .run();
+
+ const token = await createSession(env, res.meta.last_row_id);
+ return redirect(role === "pending" ? "/account" : "/stats", sessionCookie(token));
}
-async function handleStats(env: Env): Promise {
+async function handleLogin(request: Request, env: Env): Promise {
+ if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
+ const parsed = Credentials.safeParse(await formObject(request));
+ if (!parsed.success) return html(renderLogin({ kind: "err", text: "Enter a valid email and password." }));
+ const email = parsed.data.email.toLowerCase();
+
+ const row = await env.DB.prepare("SELECT id, password_hash, role FROM users WHERE email = ?1")
+ .bind(email)
+ .first<{ id: number; password_hash: string; role: Role }>();
+ const ok = row ? await verifyPassword(parsed.data.password, row.password_hash) : false;
+ if (!row || !ok) return html(renderLogin({ kind: "err", text: "Wrong email or password." }));
+
+ const token = await createSession(env, row.id);
+ return redirect(atLeast(row.role, "viewer") ? "/stats" : "/account", sessionCookie(token));
+}
+
+async function handleAccountPassword(request: Request, env: Env, user: User): Promise {
+ if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
+ const parsed = PasswordChange.safeParse(await formObject(request));
+ if (!parsed.success) return html(renderAccount(user, { kind: "err", text: "New password must be at least 8 characters." }));
+
+ const row = await env.DB.prepare("SELECT password_hash FROM users WHERE id = ?1")
+ .bind(user.id)
+ .first<{ password_hash: string }>();
+ if (!row || !(await verifyPassword(parsed.data.current, row.password_hash)))
+ return html(renderAccount(user, { kind: "err", text: "Current password is incorrect." }));
+
+ await env.DB.prepare("UPDATE users SET password_hash = ?1 WHERE id = ?2")
+ .bind(await hashPassword(parsed.data.next), user.id)
+ .run();
+ return html(renderAccount(user, { kind: "ok", text: "Password updated." }));
+}
+
+async function handleStats(env: Env, user: User): Promise {
const [daily, versions, platforms, crashes, metrics] = await Promise.all([
env.DB.prepare(
"SELECT date, COUNT(*) AS users, SUM(opens) AS opens FROM pings WHERE date >= date('now', '-29 day') GROUP BY date",
@@ -221,58 +291,172 @@ async function handleStats(env: Env): Promise {
"SELECT os || ' ' || arch AS label, COUNT(DISTINCT install_id) AS users FROM pings WHERE date >= date('now', '-6 day') GROUP BY label ORDER BY users DESC",
).all<{ label: string; users: number }>(),
env.DB.prepare(
- "SELECT fingerprint, kind, count, last_version, substr(last_seen, 1, 10) AS seen FROM groups ORDER BY last_seen DESC LIMIT 25",
- ).all<{ fingerprint: string; kind: string; count: number; last_version: string; seen: string }>(),
+ "SELECT fingerprint, kind, count, last_version, substr(last_seen, 1, 10) AS seen, status FROM groups ORDER BY last_seen DESC LIMIT 25",
+ ).all<{ fingerprint: string; kind: string; count: number; last_version: string; seen: string; status: string }>(),
env.DB.prepare(
"SELECT signal, bucket, SUM(count) AS total FROM metrics WHERE date >= date('now', '-6 day') GROUP BY signal, bucket ORDER BY signal, total DESC",
).all<{ signal: string; bucket: string; total: number }>(),
]);
return html(
- renderStats({
- daily: daily.results,
- versions: versions.results,
- platforms: platforms.results,
- crashes: crashes.results,
- metrics: metrics.results,
- }),
+ renderStats(
+ {
+ daily: daily.results,
+ versions: versions.results,
+ platforms: platforms.results,
+ crashes: crashes.results,
+ metrics: metrics.results,
+ },
+ user,
+ ),
);
}
-async function handleGroup(env: Env, fingerprint: string): Promise {
- const group = await env.DB.prepare("SELECT * FROM groups WHERE fingerprint = ?1")
- .bind(fingerprint)
- .first<{ fingerprint: string; kind: string; count: number; first_seen: string; last_seen: string; last_version: string }>();
+async function handleGroup(env: Env, fingerprint: string, user: User): Promise {
+ const group = await env.DB.prepare("SELECT * FROM groups WHERE fingerprint = ?1").bind(fingerprint).first();
if (!group) return new Response("not found", { status: 404 });
const reports = await env.DB.prepare(
"SELECT version, os, arch, message, device, created_at FROM reports WHERE fingerprint = ?1 ORDER BY id DESC",
)
.bind(fingerprint)
.all<{ version: string; os: string; arch: string; message: string; device: string; created_at: string }>();
- return html(renderGroup(group, reports.results));
+ return html(renderGroup(group, reports.results, user));
+}
+
+async function handleGroupAction(request: Request, env: Env, admin: User, fingerprint: string): Promise {
+ if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
+ const parsed = GroupAction.safeParse(await formObject(request));
+ if (!parsed.success) return redirect(`/stats/group/${fingerprint}`);
+ const a = parsed.data;
+
+ if (a.action === "delete") {
+ await env.DB.batch([
+ env.DB.prepare("DELETE FROM reports WHERE fingerprint = ?1").bind(fingerprint),
+ env.DB.prepare("DELETE FROM groups WHERE fingerprint = ?1").bind(fingerprint),
+ ]);
+ await logAction(env, admin, "delete_group", fingerprint.slice(0, 8));
+ return redirect("/stats");
+ }
+ if (a.action === "status") {
+ const status = a.status ?? "open";
+ await env.DB.prepare("UPDATE groups SET status = ?1 WHERE fingerprint = ?2").bind(status, fingerprint).run();
+ await logAction(env, admin, "set_status", fingerprint.slice(0, 8), status);
+ return redirect(`/stats/group/${fingerprint}`);
+ }
+ await env.DB.prepare("UPDATE groups SET note = ?1 WHERE fingerprint = ?2").bind(a.note ?? "", fingerprint).run();
+ await logAction(env, admin, "set_note", fingerprint.slice(0, 8));
+ return redirect(`/stats/group/${fingerprint}`);
+}
+
+async function handleAdminUsers(request: Request, env: Env, admin: User): Promise {
+ if (!sameOrigin(request)) return new Response("forbidden", { status: 403 });
+ const parsed = UserAction.safeParse(await formObject(request));
+ if (!parsed.success) return redirect("/admin");
+ const a = parsed.data;
+ if (a.userId === admin.id) return redirect("/admin");
+
+ const target = await env.DB.prepare("SELECT email, role FROM users WHERE id = ?1")
+ .bind(a.userId)
+ .first<{ email: string; role: Role }>();
+ if (!target) return redirect("/admin");
+
+ if (a.action === "delete") {
+ await env.DB.batch([
+ env.DB.prepare("DELETE FROM sessions WHERE user_id = ?1").bind(a.userId),
+ env.DB.prepare("DELETE FROM users WHERE id = ?1").bind(a.userId),
+ ]);
+ await logAction(env, admin, "delete_user", target.email);
+ return redirect("/admin");
+ }
+
+ const role: Role = a.role ?? "pending";
+ const now = new Date().toISOString();
+ await env.DB.prepare("UPDATE users SET role = ?1, approved_at = ?2, approved_by = ?3 WHERE id = ?4")
+ .bind(role, role === "pending" ? null : now, admin.id, a.userId)
+ .run();
+ await logAction(env, admin, "set_role", target.email, `${target.role} → ${role}`);
+ return redirect("/admin");
+}
+
+async function handleAdminList(env: Env, admin: User): Promise {
+ const users = await env.DB.prepare(
+ "SELECT id, email, role, created_at, approved_at FROM users ORDER BY (role = 'pending') DESC, created_at DESC",
+ ).all();
+ return html(renderUsers(admin, users.results));
+}
+
+async function handleAdminAudit(env: Env, admin: User): Promise {
+ const rows = await env.DB.prepare(
+ "SELECT at, actor_email, action, target, detail FROM audit_log ORDER BY id DESC LIMIT 200",
+ ).all();
+ return html(renderAudit(admin, rows.results));
+}
+
+function requireViewer(user: User | null): Response | null {
+ if (!user) return redirect("/login");
+ if (!atLeast(user.role, "viewer")) return redirect("/account");
+ return null;
}
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
- if (url.pathname === "/v1/report" && request.method === "POST") return handleReport(request, env);
- if (url.pathname === "/v1/ping" && request.method === "POST") return handlePing(request, env);
- if (url.pathname === "/v1/metrics" && request.method === "POST") return handleMetrics(request, env);
+ const path = url.pathname;
+ const method = request.method;
- const group = url.pathname.match(/^\/stats\/group\/([0-9a-f]{64})$/);
- if ((url.pathname === "/stats" || group) && request.method === "GET") {
- if (!statsAuthorized(request, env)) {
- return new Response("auth required", {
- status: 401,
- headers: { "www-authenticate": 'Basic realm="reasonix-stats"' },
- });
- }
- return group ? handleGroup(env, group[1]) : handleStats(env);
+ 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);
+
+ if (path === "/register" && method === "GET") return html(renderRegister());
+ if (path === "/register" && method === "POST") return handleRegister(request, env);
+ if (path === "/login" && method === "GET") return html(renderLogin());
+ if (path === "/login" && method === "POST") return handleLogin(request, env);
+ if (path === "/logout" && method === "POST") {
+ await endSession(request, env);
+ return redirect("/login", clearCookie());
}
+
+ const user = await currentUser(request, env);
+
+ if (path === "/") return redirect(user ? (atLeast(user.role, "viewer") ? "/stats" : "/account") : "/login");
+
+ if (path === "/account" && method === "GET")
+ return user ? html(renderAccount(user)) : redirect("/login");
+ if (path === "/account/password" && method === "POST")
+ return user ? handleAccountPassword(request, env, user) : redirect("/login");
+
+ const groupMatch = path.match(/^\/stats\/group\/([0-9a-f]{64})$/);
+ if (path === "/stats" && method === "GET") return requireViewer(user) ?? handleStats(env, user as User);
+ if (groupMatch && method === "GET") return requireViewer(user) ?? handleGroup(env, groupMatch[1], user as User);
+ if (groupMatch && method === "POST") {
+ if (user?.role !== "admin") return new Response("forbidden", { status: 403 });
+ return handleGroupAction(request, env, user, groupMatch[1]);
+ }
+
+ if (path === "/admin" && method === "GET") {
+ if (!user) return redirect("/login");
+ return user.role === "admin" ? handleAdminList(env, user) : redirect("/account");
+ }
+ if (path === "/admin/audit" && method === "GET") {
+ if (!user) return redirect("/login");
+ return user.role === "admin" ? handleAdminAudit(env, user) : redirect("/account");
+ }
+ if (path === "/admin/users" && method === "POST") {
+ if (user?.role !== "admin") return new Response("forbidden", { status: 403 });
+ return handleAdminUsers(request, env, user);
+ }
+
if (
- url.pathname === "/v1/report" ||
- url.pathname === "/v1/ping" ||
- url.pathname === "/v1/metrics" ||
- url.pathname.startsWith("/stats")
+ path === "/v1/report" ||
+ path === "/v1/ping" ||
+ path === "/v1/metrics" ||
+ path === "/login" ||
+ path === "/register" ||
+ path === "/logout" ||
+ path === "/account" ||
+ path === "/account/password" ||
+ path.startsWith("/stats") ||
+ path.startsWith("/admin")
) {
return new Response("method not allowed", { status: 405 });
}
diff --git a/workers/crash-report/src/shell.ts b/workers/crash-report/src/shell.ts
new file mode 100644
index 0000000000..bc1b272cf5
--- /dev/null
+++ b/workers/crash-report/src/shell.ts
@@ -0,0 +1,106 @@
+// Shared page shell + UI kit. Visual language mirrors site/src/styles/global.css
+// — white, blue accent, large radii; crash stacks use the site's dark terminal.
+
+export function esc(s: unknown): string {
+ return String(s).replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`);
+}
+
+const LOGO = `R `;
+
+const CSS = `
+:root{--accent:oklch(0.55 0.17 257);--accent-soft:color-mix(in oklch,var(--accent) 8%,white);
+--bg-soft:oklch(0.976 0.0025 247);--ink:oklch(0.25 0.006 260);--ink-2:oklch(0.47 0.008 260);
+--ink-3:oklch(0.6 0.008 260);--line:oklch(0.925 0.004 247);--term-bg:oklch(0.25 0.006 260);
+--shadow-1:0 1px 2px oklch(0.35 0.01 260/0.18),0 1px 3px 1px oklch(0.35 0.01 260/0.08);
+--sans:"Outfit","Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif;
+--mono:"JetBrains Mono","SF Mono",Menlo,Consolas,monospace}
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:var(--sans);background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased}
+.wrap{max-width:1060px;margin:0 auto;padding:0 28px 64px}
+header{display:flex;align-items:center;gap:10px;height:68px}
+.logo{width:28px;height:28px;border-radius:8px}
+header .brand{font-size:17px;font-weight:600;color:var(--ink);text-decoration:none}
+header .crumb{color:var(--ink-3);font-size:15px}
+header .nav{margin-left:auto;display:flex;align-items:center;gap:14px}
+.navlink{color:var(--accent);text-decoration:none;font-size:14px;font-weight:500}
+.navlink:hover{text-decoration:underline}
+.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--ink-2)}
+.badge{font-size:11px;font-weight:600;letter-spacing:.02em;text-transform:uppercase;padding:2px 8px;border-radius:999px}
+.badge.pending{background:oklch(0.95 0.04 80);color:oklch(0.5 0.12 80)}
+.badge.viewer{background:var(--accent-soft);color:var(--accent)}
+.badge.admin{background:oklch(0.95 0.05 150);color:oklch(0.48 0.13 150)}
+h1{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin:18px 0 6px}
+.sub{color:var(--ink-2);font-size:14.5px;margin-bottom:28px}
+.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
+.card{background:#fff;border:1px solid var(--line);border-radius:24px;padding:24px 28px;box-shadow:var(--shadow-1)}
+.card.full{grid-column:1/-1}
+.card h2{font-size:15px;font-weight:600;color:var(--ink-2);margin-bottom:16px}
+.card h2 b{color:var(--ink)}
+.empty{color:var(--ink-3);font-size:14px;padding:14px 0}
+svg.chart{width:100%;height:auto;display:block}
+.row{display:grid;grid-template-columns:minmax(90px,auto) 1fr 3.5em;align-items:center;gap:12px;padding:7px 0;font-size:14px}
+.row+.row{border-top:1px solid var(--line)}
+.row .bar{height:10px;border-radius:999px;background:linear-gradient(90deg,#4f9dff,#7a6bff);min-width:4px}
+.row .n{text-align:right;font-family:var(--mono);font-size:13px;color:var(--ink-2)}
+table{border-collapse:collapse;width:100%;font-size:14px}
+th{text-align:left;color:var(--ink-3);font-weight:500;font-size:12.5px;padding:0 14px 8px 0}
+td{padding:8px 14px 8px 0;border-top:1px solid var(--line);vertical-align:middle}
+td.n{font-family:var(--mono);font-size:13px}
+a.fp{font-family:var(--mono);font-size:13px;color:var(--accent);text-decoration:none}
+a.fp:hover{text-decoration:underline}
+.pill{display:inline-block;font-size:12px;font-weight:500;padding:2px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
+.pill.crash{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)}
+.pill.resolved{background:oklch(0.95 0.05 150);color:oklch(0.48 0.13 150)}
+.pill.ignored{background:var(--bg-soft);color:var(--ink-3)}
+.back{display:inline-block;margin:18px 0 0;color:var(--accent);text-decoration:none;font-size:14px}
+.report{margin-top:20px}
+.report .meta{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--ink-2);margin-bottom:10px}
+.report .meta b{color:var(--ink);font-weight:500}
+pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px;font-family:var(--mono);
+font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word}
+.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px}
+.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px}
+form.inline{display:inline}
+.authwrap{max-width:400px;margin:6vh auto 0}
+.authcard{background:#fff;border:1px solid var(--line);border-radius:24px;padding:32px 34px;box-shadow:var(--shadow-1)}
+.authcard h1{font-size:23px;margin:0 0 4px}
+.authcard .sub{margin-bottom:22px}
+.field{display:block;margin-bottom:14px}
+.field label{display:block;font-size:13px;color:var(--ink-2);margin-bottom:6px}
+.field input,select{width:100%;font:inherit;font-size:14.5px;color:var(--ink);background:#fff;border:1px solid var(--line);border-radius:12px;padding:10px 13px;outline:none}
+.field input:focus,select:focus{border-color:var(--accent)}
+.btn{font:inherit;font-size:14.5px;font-weight:600;color:#fff;background:var(--accent);border:none;border-radius:12px;padding:10px 18px;cursor:pointer}
+.btn:hover{filter:brightness(1.05)}
+.btn.block{width:100%}
+.btn.ghost{background:var(--accent-soft);color:var(--accent)}
+.btn.danger{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)}
+.btn.sm{font-size:13px;padding:6px 12px;border-radius:10px}
+.notice{font-size:13.5px;padding:10px 14px;border-radius:12px;margin-bottom:16px}
+.notice.err{background:oklch(0.96 0.03 25);color:oklch(0.5 0.18 25)}
+.notice.ok{background:oklch(0.95 0.05 150);color:oklch(0.45 0.13 150)}
+.alt{margin-top:18px;font-size:13.5px;color:var(--ink-3);text-align:center}
+.alt a{color:var(--accent);text-decoration:none}
+.actions{display:flex;gap:8px;align-items:center}
+td select{width:auto;padding:6px 10px;border-radius:10px}
+.note-edit{margin-top:12px;display:flex;gap:8px;max-width:560px}
+.note-edit input{flex:1}
+@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}}
+`;
+
+export function page(title: string, crumb: string, body: string, nav = ""): string {
+ return `
+
+${esc(title)} `;
+}
+
+export function html(body: string): Response {
+ return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
+}
+
+export function redirect(location: string, cookie?: string): Response {
+ const headers: Record = { location };
+ if (cookie) headers["set-cookie"] = cookie;
+ return new Response(null, { status: 303, headers });
+}
diff --git a/workers/crash-report/src/stats.ts b/workers/crash-report/src/stats.ts
index 4d0fc7a4d4..4a23bfb411 100644
--- a/workers/crash-report/src/stats.ts
+++ b/workers/crash-report/src/stats.ts
@@ -1,65 +1,5 @@
-// Stats pages. Visual language mirrors site/src/styles/global.css — white, blue
-// accent, large radii; crash stacks render in the site's dark terminal style.
-
-export function esc(s: unknown): string {
- return String(s).replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`);
-}
-
-const LOGO = `R `;
-
-const CSS = `
-:root{--accent:oklch(0.55 0.17 257);--accent-soft:color-mix(in oklch,var(--accent) 8%,white);
---bg-soft:oklch(0.976 0.0025 247);--ink:oklch(0.25 0.006 260);--ink-2:oklch(0.47 0.008 260);
---ink-3:oklch(0.6 0.008 260);--line:oklch(0.925 0.004 247);--term-bg:oklch(0.25 0.006 260);
---shadow-1:0 1px 2px oklch(0.35 0.01 260/0.18),0 1px 3px 1px oklch(0.35 0.01 260/0.08);
---sans:"Outfit","Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif;
---mono:"JetBrains Mono","SF Mono",Menlo,Consolas,monospace}
-*{margin:0;padding:0;box-sizing:border-box}
-body{font-family:var(--sans);background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased}
-.wrap{max-width:1060px;margin:0 auto;padding:0 28px 64px}
-header{display:flex;align-items:center;gap:10px;height:68px}
-.logo{width:28px;height:28px;border-radius:8px}
-header .brand{font-size:17px;font-weight:600;color:var(--ink);text-decoration:none}
-header .crumb{color:var(--ink-3);font-size:15px}
-h1{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin:18px 0 6px}
-.sub{color:var(--ink-2);font-size:14.5px;margin-bottom:28px}
-.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}
-.card{background:#fff;border:1px solid var(--line);border-radius:24px;padding:24px 28px;box-shadow:var(--shadow-1)}
-.card.full{grid-column:1/-1}
-.card h2{font-size:15px;font-weight:600;color:var(--ink-2);margin-bottom:16px}
-.card h2 b{color:var(--ink)}
-.empty{color:var(--ink-3);font-size:14px;padding:14px 0}
-svg.chart{width:100%;height:auto;display:block}
-.row{display:grid;grid-template-columns:minmax(90px,auto) 1fr 3.5em;align-items:center;gap:12px;padding:7px 0;font-size:14px}
-.row+.row{border-top:1px solid var(--line)}
-.row .bar{height:10px;border-radius:999px;background:linear-gradient(90deg,#4f9dff,#7a6bff);min-width:4px}
-.row .n{text-align:right;font-family:var(--mono);font-size:13px;color:var(--ink-2)}
-table{border-collapse:collapse;width:100%;font-size:14px}
-th{text-align:left;color:var(--ink-3);font-weight:500;font-size:12.5px;padding:0 14px 8px 0}
-td{padding:8px 14px 8px 0;border-top:1px solid var(--line)}
-td.n{font-family:var(--mono);font-size:13px}
-a.fp{font-family:var(--mono);font-size:13px;color:var(--accent);text-decoration:none}
-a.fp:hover{text-decoration:underline}
-.pill{display:inline-block;font-size:12px;font-weight:500;padding:2px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
-.pill.crash{background:oklch(0.95 0.03 25);color:oklch(0.55 0.18 25)}
-.back{display:inline-block;margin:18px 0 0;color:var(--accent);text-decoration:none;font-size:14px}
-.report{margin-top:20px}
-.report .meta{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--ink-2);margin-bottom:10px}
-.report .meta b{color:var(--ink);font-weight:500}
-pre{background:var(--term-bg);color:#e6e8f0;border-radius:12px;padding:16px 18px;font-family:var(--mono);
-font-size:12.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word}
-.metrics{display:grid;grid-template-columns:1fr 1fr;gap:8px 28px}
-.metric-block h3{font-family:var(--mono);font-size:12.5px;font-weight:600;color:var(--ink-2);margin:6px 0 4px}
-@media(max-width:760px){.grid{grid-template-columns:1fr}.metrics{grid-template-columns:1fr}.wrap{padding:0 16px 48px}}
-`;
-
-function page(title: string, crumb: string, body: string): string {
- return `
-
-${esc(title)} `;
-}
+import { esc, page } from "./shell";
+import { type User, userNav } from "./auth";
type Daily = { date: string; users: number; opens: number };
@@ -120,21 +60,32 @@ function metricsCards(rows: { signal: string; bucket: string; total: number }[])
.join("")}`;
}
-export function renderStats(data: {
- daily: Daily[];
- versions: { label: string; users: number }[];
- platforms: { label: string; users: number }[];
- crashes: { fingerprint: string; kind: string; count: number; last_version: string; seen: string }[];
- metrics: { signal: string; bucket: string; total: number }[];
-}): string {
+function statusPill(status: string): string {
+ if (status === "resolved") return `resolved `;
+ if (status === "ignored") return `ignored `;
+ return "";
+}
+
+type CrashRow = { fingerprint: string; kind: string; count: number; last_version: string; seen: string; status: string };
+
+export function renderStats(
+ data: {
+ daily: Daily[];
+ versions: { label: string; users: number }[];
+ platforms: { label: string; users: number }[];
+ crashes: CrashRow[];
+ metrics: { signal: string; bucket: string; total: number }[];
+ },
+ user: User,
+): string {
const days = last30Days(data.daily);
const totalUsers = days.at(-1)?.users ?? 0;
const anyPing = days.some((d) => d.opens > 0);
const crashRows = data.crashes.length
- ? `fingerprint kind count last version last seen ${data.crashes
+ ? ``
: `No crash reports yet — that's the good kind of empty · 还没有崩溃报告
`;
@@ -151,6 +102,7 @@ ${anyPing ? dailyChart(days) : `No pings yet — data starts
Agent signals · 运行指标 — 7 days, opt-in aggregate ${metricsCards(data.metrics)}
Crash groups · 崩溃分组 — click a fingerprint for stacks ${crashRows}
`,
+ userNav(user),
);
}
@@ -165,9 +117,33 @@ function fmtDevice(deviceJSON: string): string {
}
}
+export type Group = {
+ fingerprint: string;
+ kind: string;
+ count: number;
+ first_seen: string;
+ last_seen: string;
+ last_version: string;
+ status: string;
+ note: string;
+};
+
+function manageGroup(group: Group): string {
+ const fp = esc(group.fingerprint);
+ const setStatus = (s: string, label: string, cls: string) =>
+ group.status === s
+ ? ""
+ : ``;
+ return `Manage — admin
+
${setStatus("resolved", "Mark resolved", "ghost")}${setStatus("ignored", "Ignore", "ghost")}${setStatus("open", "Reopen", "ghost")}
+
+
`;
+}
+
export function renderGroup(
- group: { fingerprint: string; kind: string; count: number; first_seen: string; last_seen: string; last_version: string },
+ group: Group,
reports: { version: string; os: string; arch: string; message: string; device: string; created_at: string }[],
+ user: User,
): string {
const samples = reports.length
? reports
@@ -179,13 +155,16 @@ export function renderGroup(
})
.join("")
: `No raw samples stored for this group
`;
+ const noteLine = group.note ? ` · note: ${esc(group.note)}` : "";
return page(
`Reasonix · ${group.fingerprint.slice(0, 8)}`,
`stats / ${group.fingerprint.slice(0, 8)}`,
- `${esc(group.kind)} ${esc(group.fingerprint.slice(0, 8))}
-${group.count} occurrences · first ${esc(group.first_seen.slice(0, 10))} · last ${esc(group.last_seen.slice(0, 10))} on ${esc(group.last_version)}
+ `${esc(group.kind)} ${esc(group.fingerprint.slice(0, 8))} ${statusPill(group.status)}
+${group.count} occurrences · first ${esc(group.first_seen.slice(0, 10))} · last ${esc(group.last_seen.slice(0, 10))} on ${esc(group.last_version)}${noteLine}
Samples — newest first, up to 5 kept ${samples}
+${user.role === "admin" ? manageGroup(group) : ""}
← Back to stats `,
+ userNav(user),
);
}