feat(crash): unify dashboard auth onto id.reasonix.io identity (#5701)

The crash dashboard ran its own account system — a separate D1 users/
sessions table with its own password login — which fragmented identity
from id.reasonix.io. Make it consume the shared identity instead: it
resolves the .reasonix.io rxid cookie via id.reasonix.io/me (Bearer) and
keeps only a per-dashboard role in a new `access` table. Existing roles
migrate by email (migrate-access.sql); ADMIN_EMAILS force-promotes the
owner so a missed migration can't lock them out. Sign-in redirects to the
shared login; sign-out revokes the shared session.

Also: accounts /auth/logout accepts a Bearer token (so crash can revoke
server-side), and the site login honours a next to any *.reasonix.io.
This commit is contained in:
YHH authored and GitHub committed 2026-07-01 16:26:24 +08:00
1 parent ac2f6695c5
commit 1858974dc7
10 files changed
+164 -228

No files matched your search

+14 -2
View File
@@ -24,6 +24,19 @@ const $ = (id) => document.getElementById(id);
const qp = new URLSearchParams(location.search);
const withBase = (p) => (import.meta.env.BASE_URL.replace(/\/$/, "") + p) || p;
// A `next` is honoured only if it's a same-origin path or an absolute https URL
// under reasonix.io, so a subdomain (e.g. crash.reasonix.io) can return here
// after sign-in without opening a redirect to an arbitrary host.
function safeNext(next) {
if (!next) return null;
if (next.startsWith("/")) return next;
try {
const u = new URL(next);
if (u.protocol === "https:" && (u.host === "reasonix.io" || u.host.endsWith(".reasonix.io"))) return next;
} catch {}
return null;
}
function msg(el, kind, text) {
if (!el) return;
el.className = "auth-msg " + kind;
@@ -52,8 +65,7 @@ if (loginForm) {
busy(btn, true);
try {
await api("/auth/login", { method: "POST", body: { email: $("email").value.trim(), password: $("password").value } });
const next = qp.get("next");
location.href = next && next.startsWith("/") ? next : withBase("/account/");
location.href = safeNext(qp.get("next")) || withBase("/account/");
} catch (err) {
msg(box, "error", err.message);
busy(btn, false);
+3 -3
View File
@@ -6,9 +6,9 @@ import { repos } from "../db";
import { readSessionToken } from "../auth/cookies";
import { ApiError } from "./errors";
// Non-browser clients (CLI/desktop) carry the session in an Authorization header
// instead of the cookie.
function readBearerToken(c: Context<AppEnv>): string | undefined {
// Non-browser clients (CLI/desktop) and cross-service callers carry the session
// in an Authorization header instead of the cookie.
export function readBearerToken(c: Context<AppEnv>): string | undefined {
const header = c.req.header("authorization");
if (!header) return undefined;
const token = /^Bearer\s+(.+)$/i.exec(header.trim())?.[1]?.trim();
+2 -1
View File
@@ -7,6 +7,7 @@ import { buildMailer, verifyEmail, resetEmail, accountExistsEmail } from "../ema
import { hashPassword, verifyPassword, randomSuffix } from "../auth/crypto";
import { setSessionCookie, clearSessionCookie, readSessionToken } from "../auth/cookies";
import { authRateLimit } from "../http/ratelimit";
import { readBearerToken } from "../http/auth";
import { ApiError } from "../http/errors";
import { deriveHandleBase, isValidHandle } from "../lib/handle";
import { parseBody, RegisterSchema, LoginSchema, ForgotSchema, ResetSchema, ResendSchema } from "../lib/validation";
@@ -96,7 +97,7 @@ auth.post("/login", async (c) => {
});
auth.post("/logout", async (c) => {
const token = readSessionToken(c);
const token = readSessionToken(c) ?? readBearerToken(c);
if (token) await repos(c.env).sessions.deleteByToken(token);
clearSessionCookie(c);
return c.json({ ok: true });
+17
View File
@@ -0,0 +1,17 @@
-- Unify dashboard auth onto id.reasonix.io: dashboard access is now a per-email
-- role, not a local password/session. Identity is resolved from the shared
-- account service; this table only records who may view the dashboard.
-- Apply: wrangler d1 execute reasonix-crash --remote --file=migrate-access.sql
CREATE TABLE IF NOT EXISTS access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
approved_at TEXT,
approved_by TEXT
);
-- Carry over every existing dashboard account's role by email, so current
-- admins/viewers keep their access when they next sign in via id.reasonix.io.
INSERT OR IGNORE INTO access (email, role, created_at, approved_at)
SELECT lower(email), role, created_at, approved_at FROM users;
+13
View File
@@ -87,6 +87,8 @@ CREATE TABLE IF NOT EXISTS metric_users (
PRIMARY KEY (date, signal, bucket, install_id)
);
-- Legacy local auth — superseded by id.reasonix.io identity + the `access`
-- table below. Kept during the transition; migrate-access.sql copies roles over.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
@@ -106,6 +108,17 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS sessions_user ON sessions (user_id);
-- Dashboard authorization keyed by the shared account email. Identity (login,
-- password, verification) lives in id.reasonix.io; this only maps email → role.
CREATE TABLE IF NOT EXISTS access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
approved_at TEXT,
approved_by TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
+76 -76
View File
@@ -1,3 +1,6 @@
// Dashboard authorization. Identity comes from id.reasonix.io (the shared
// account service); this worker only maps a signed-in identity to a per-dashboard
// role via the `access` table.
import type { Env } from "./env";
import { esc } from "./shell";
@@ -17,72 +20,16 @@ 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;
// The id.reasonix.io session cookie is scoped to `.reasonix.io`, so the browser
// sends it here too; we hand it back as a Bearer token to resolve the identity.
const SHARED_COOKIE = "rxid";
function b64(bytes: Uint8Array): string {
let s = "";
for (const byte of bytes) s += String.fromCharCode(byte);
return btoa(s);
function idOrigin(env: Env): string {
return (env.ID_ORIGIN ?? "https://id.reasonix.io").replace(/\/$/, "");
}
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<Uint8Array> {
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<string> {
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<boolean> {
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<string> {
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<void> {
await env.DB.prepare("DELETE FROM sessions WHERE token = ?1").bind(token).run();
}
export async function endSession(request: Request, env: Env): Promise<void> {
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`;
function appOrigin(env: Env): string {
return (env.APP_ORIGIN ?? "https://reasonix.io").replace(/\/$/, "");
}
export function getCookie(request: Request, name: string): string | null {
@@ -94,21 +41,74 @@ export function getCookie(request: Request, name: string): string | null {
return null;
}
export async function currentUser(request: Request, env: Env): Promise<User | null> {
const token = getCookie(request, COOKIE);
// Where an unauthenticated visitor is sent to sign in: the shared login page,
// with a same-site `next` back to the page they wanted.
export function loginUrl(env: Env, request: Request): string {
const here = new URL(request.url);
const next = `${here.origin}${here.pathname}`;
return `${appOrigin(env)}/login/?next=${encodeURIComponent(next)}`;
}
interface Identity {
email: string;
emailVerified: boolean;
}
async function resolveIdentity(request: Request, env: Env): Promise<Identity | null> {
const token = getCookie(request, SHARED_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;
const res = await fetch(`${idOrigin(env)}/me`, { headers: { authorization: `Bearer ${token}` } });
if (!res.ok) return null;
const data = await res.json<{ user?: { email?: string; emailVerified?: boolean } }>().catch(() => null);
const email = data?.user?.email?.toLowerCase();
if (!email) return null;
return { email, emailVerified: data?.user?.emailVerified === true };
}
// Resolves the signed-in dashboard user, or null. A first-seen identity is
// recorded as `pending`; an ADMIN_EMAILS identity is force-promoted to admin so
// the owner can never be locked out of their own dashboard.
export async function currentUser(request: Request, env: Env): Promise<User | null> {
const identity = await resolveIdentity(request, env);
if (!identity) return null;
const now = new Date().toISOString();
const bootstrapAdmin = isAdminEmail(env, identity.email);
let row = await selectAccess(env, identity.email);
if (!row) {
await env.DB.prepare(
"INSERT INTO access (email, role, created_at, approved_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(email) DO NOTHING",
)
.bind(identity.email, bootstrapAdmin ? "admin" : "pending", now, bootstrapAdmin ? now : null)
.run();
row = await selectAccess(env, identity.email);
if (!row) return null;
} else if (bootstrapAdmin && row.role !== "admin") {
await env.DB.prepare("UPDATE access SET role = 'admin', approved_at = COALESCE(approved_at, ?2) WHERE id = ?1")
.bind(row.id, now)
.run();
row.role = "admin";
}
return { id: row.id, email: row.email, role: row.role, created_at: row.created_at, approved_at: row.approved_at };
return row;
}
function selectAccess(env: Env, email: string): Promise<User | null> {
return env.DB.prepare("SELECT id, email, role, created_at, approved_at FROM access WHERE email = ?1")
.bind(email)
.first<User>();
}
// Ends the shared id.reasonix.io session (best-effort) and returns a Set-Cookie
// that clears the shared cookie browser-side.
export async function sharedLogout(request: Request, env: Env): Promise<string> {
const token = getCookie(request, SHARED_COOKIE);
if (token) {
await fetch(`${idOrigin(env)}/auth/logout`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
}).catch(() => {});
}
return `${SHARED_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=.reasonix.io; Max-Age=0`;
}
export function isAdminEmail(env: Env, email: string): boolean {
+5 -44
View File
@@ -1,43 +1,7 @@
import { esc, page } from "./shell";
import { type User, userNav } from "./auth";
function notice(n?: { kind: "err" | "ok"; text: string }): string {
return n ? `<div class="notice ${n.kind}">${esc(n.text)}</div>` : "";
}
function authShell(title: string, crumb: string, body: string): string {
return page(title, crumb, `<div class="authwrap"><div class="authcard">${body}</div></div>`);
}
export function renderLogin(n?: { kind: "err" | "ok"; text: string }): string {
return authShell(
"Reasonix · Sign in",
"sign in",
`<h1>Sign in</h1><p class="sub">Diagnostic reports &amp; telemetry dashboard</p>${notice(n)}
<form method="post" action="/login">
<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required></div>
<div class="field"><label>Password</label><input type="password" name="password" autocomplete="current-password" required></div>
<button class="btn block" type="submit">Sign in</button>
</form>
<div class="alt">No account? <a href="/register">Register</a></div>`,
);
}
export function renderRegister(n?: { kind: "err" | "ok"; text: string }): string {
return authShell(
"Reasonix · Register",
"register",
`<h1>Create account</h1><p class="sub">New accounts start with no access until an admin approves them.</p>${notice(n)}
<form method="post" action="/register">
<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required></div>
<div class="field"><label>Password <span style="color:var(--ink-3)">— at least 8 characters</span></label><input type="password" name="password" autocomplete="new-password" minlength="8" required></div>
<button class="btn block" type="submit">Register</button>
</form>
<div class="alt">Already have an account? <a href="/login">Sign in</a></div>`,
);
}
export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string }): string {
export function renderAccount(user: User): string {
const pending = user.role === "pending";
const status = pending
? `<div class="notice err">Your account is awaiting approval. An admin needs to grant you access before the diagnostic reports dashboard becomes visible.</div>`
@@ -46,13 +10,10 @@ export function renderAccount(user: User, n?: { kind: "err" | "ok"; text: string
"Reasonix · Account",
"account",
`<h1>Account</h1><p class="sub">${esc(user.email)} · joined ${esc(user.created_at.slice(0, 10))}</p>
${notice(n)}${status}
<div class="card" style="max-width:480px;margin-top:24px"><h2>Change password</h2>
<form method="post" action="/account/password">
<div class="field"><label>Current password</label><input type="password" name="current" autocomplete="current-password" required></div>
<div class="field"><label>New password</label><input type="password" name="next" autocomplete="new-password" minlength="8" required></div>
<button class="btn" type="submit">Update password</button>
</form></div>`,
${status}
<div class="card" style="max-width:480px;margin-top:24px"><h2>Identity</h2>
<p class="sub">Your email and password are managed by your Reasonix account.</p>
<a class="btn" href="https://reasonix.io/account/">Manage your account →</a></div>`,
userNav(user),
);
}
+4
View File
@@ -8,4 +8,8 @@ export interface Env {
PING_LIMITER: RateLimiter;
METRICS_LIMITER: RateLimiter;
ADMIN_EMAILS?: string;
// Shared identity service (id.reasonix.io) and the site that hosts its login
// page (reasonix.io). Overridable for local dev.
ID_ORIGIN?: string;
APP_ORIGIN?: string;
}
+21 -102
View File
@@ -5,20 +5,15 @@ import { z } from "zod";
import type { Env } from "./env";
import { html, redirect } from "./shell";
import { renderGroup, renderStats, type Group, type StatsModule } from "./stats";
import { renderLogin, renderRegister, renderAccount } from "./auth_pages";
import { renderAccount } from "./auth_pages";
import { renderUsers, renderAudit, type UserRow, type AuditRow } from "./admin";
import {
atLeast,
createSession,
currentUser,
endSession,
hashPassword,
isAdminEmail,
loginUrl,
logAction,
sameOrigin,
sessionCookie,
clearCookie,
verifyPassword,
sharedLogout,
type Role,
type User,
} from "./auth";
@@ -472,16 +467,6 @@ async function handleMetrics(request: Request, env: Env): Promise<Response> {
return new Response("ok", { status: 202 });
}
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(),
@@ -503,62 +488,6 @@ async function formObject(request: Request): Promise<Record<string, string>> {
return out;
}
async function handleRegister(request: Request, env: Env): Promise<Response> {
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 handleLogin(request: Request, env: Env): Promise<Response> {
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<Response> {
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." }));
}
type StatsFilters = {
status: string;
source: string;
@@ -899,24 +828,21 @@ async function handleAdminUsers(request: Request, env: Env, admin: User): Promis
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")
const target = await env.DB.prepare("SELECT email, role FROM access 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 env.DB.prepare("DELETE FROM access WHERE id = ?1").bind(a.userId).run();
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)
await env.DB.prepare("UPDATE access SET role = ?1, approved_at = ?2, approved_by = ?3 WHERE id = ?4")
.bind(role, role === "pending" ? null : now, admin.email, a.userId)
.run();
await logAction(env, admin, "set_role", target.email, `${target.role} → ${role}`);
return redirect("/admin");
@@ -924,7 +850,7 @@ async function handleAdminUsers(request: Request, env: Env, admin: User): Promis
async function handleAdminList(env: Env, admin: User): Promise<Response> {
const users = await env.DB.prepare(
"SELECT id, email, role, created_at, approved_at FROM users ORDER BY (role = 'pending') DESC, created_at DESC",
"SELECT id, email, role, created_at, approved_at FROM access ORDER BY (role = 'pending') DESC, created_at DESC",
).all<UserRow>();
return html(renderUsers(admin, users.results));
}
@@ -936,8 +862,8 @@ async function handleAdminAudit(env: Env, admin: User): Promise<Response> {
return html(renderAudit(admin, rows.results));
}
function requireViewer(user: User | null): Response | null {
if (!user) return redirect("/login");
function requireViewer(user: User | null, login: string): Response | null {
if (!user) return redirect(login);
if (!atLeast(user.role, "viewer")) return redirect("/account");
return null;
}
@@ -952,40 +878,34 @@ export default {
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 login = loginUrl(env, request);
// Authentication moved to id.reasonix.io; these paths just bounce there.
if ((path === "/login" || path === "/register") && method === "GET") return redirect(login);
if (path === "/logout" && method === "POST") return redirect(login, await sharedLogout(request, env));
const user = await currentUser(request, env);
if (path === "/") return redirect(user ? (atLeast(user.role, "viewer") ? "/stats" : "/account") : "/login");
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");
if (path === "/account" && method === "GET") return user ? html(renderAccount(user)) : redirect(login);
const groupMatch = path.match(/^\/stats\/group\/([0-9a-f]{64})$/);
const statsModuleMatch = path.match(/^\/stats\/(diagnostics|usage|preferences|health)$/);
if ((path === "/stats" || statsModuleMatch) && method === "GET")
return requireViewer(user) ?? handleStats(request, env, user as User, (statsModuleMatch?.[1] as StatsModule | undefined) ?? "usage");
if (groupMatch && method === "GET") return requireViewer(user) ?? handleGroup(env, groupMatch[1], user as User);
return requireViewer(user, login) ?? handleStats(request, env, user as User, (statsModuleMatch?.[1] as StatsModule | undefined) ?? "usage");
if (groupMatch && method === "GET") return requireViewer(user, login) ?? 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");
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");
if (!user) return redirect(login);
return user.role === "admin" ? handleAdminAudit(env, user) : redirect("/account");
}
if (path === "/admin/users" && method === "POST") {
@@ -1001,7 +921,6 @@ export default {
path === "/register" ||
path === "/logout" ||
path === "/account" ||
path === "/account/password" ||
path.startsWith("/stats") ||
path.startsWith("/admin")
) {
+9
View File
@@ -5,6 +5,15 @@ compatibility_date = "2026-06-01"
routes = [{ pattern = "crash.reasonix.io", custom_domain = true }]
[vars]
# Emails force-promoted to dashboard admin — the owner is never locked out even
# if the role migration misses them.
ADMIN_EMAILS = "[email protected]"
# Shared identity service (resolves the session) and the site hosting its login
# page. Override for local dev.
ID_ORIGIN = "https://id.reasonix.io"
APP_ORIGIN = "https://reasonix.io"
[[d1_databases]]
binding = "DB"
database_name = "reasonix-crash"