Skip to content
536 changes: 536 additions & 0 deletions src/admin/admin-css.ts

Large diffs are not rendered by default.

12 changes: 2 additions & 10 deletions src/admin/auth/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,11 @@

import { createHmac } from "node:crypto";
import type { AdminState } from "../types.ts";
import { timingSafeEqual } from "../timing-safe.ts";

const SECRET_FILE = "csrf-secret";
const secretCache = new Map<string, string>();

function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
const maxLen = Math.max(a.length, b.length);
let result = a.length ^ b.length;
for (let i = 0; i < maxLen; i++) {
result |= (a[i] ?? 0) ^ (b[i] ?? 0);
}
return result === 0;
}

/** HMAC-SHA256 hex digest bound to one session id. */
export function mintCsrfToken(sessionId: string, secret: string): string {
return createHmac("sha256", secret).update(sessionId).digest("hex");
Expand All @@ -36,7 +28,7 @@ export function csrfTokenMatches(
): boolean {
if (!sessionId || !secret || !presented) return false;
const expected = mintCsrfToken(sessionId, secret);
return timingSafeEqualBytes(
return timingSafeEqual(
new TextEncoder().encode(expected),
new TextEncoder().encode(presented),
);
Expand Down
17 changes: 1 addition & 16 deletions src/admin/auth/passwords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { encodeHex } from "@std/encoding/hex";
import { timingSafeEqual } from "../timing-safe.ts";

/**
* Pre-computed PBKDF2 hash used for constant-time dummy comparisons.
Expand Down Expand Up @@ -120,19 +121,3 @@ function hexToBytes(hex: string): Uint8Array {
return bytes;
}

/**
* Constant-time comparison of two byte arrays.
*
* Runs for max(a.length, b.length) iterations regardless of content,
* and captures a length difference in the initial XOR — no early returns.
* Operating on raw bytes (not hex strings) removes the encoding layer.
*/
function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
const maxLen = Math.max(a.length, b.length);
// Start with the length difference: if lengths differ, result is already non-zero.
let result = a.length ^ b.length;
for (let i = 0; i < maxLen; i++) {
result |= (a[i] ?? 0) ^ (b[i] ?? 0);
}
return result === 0;
}
4 changes: 2 additions & 2 deletions src/admin/auth/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,13 @@ export function createUserManager(config: UserManagerConfig): UserManager {

if (admins.length > 0) {
if (admins.length > 1) {
console.warn(` ⚠️ Found ${admins.length} enabled admin users (${admins.map((u) => u.username).join(", ")})`);
console.warn(`[dune/users] ⚠️ Found ${admins.length} enabled admin users (${admins.map((u) => u.username).join(", ")})`);
}
return { created: false };
}

if (allUsers.length > 0) {
console.warn(` ⚠️ Found ${allUsers.length} user(s) but none are enabled admins — creating default admin`);
console.warn(`[dune/users] ⚠️ Found ${allUsers.length} user(s) but none are enabled admins — creating default admin`);
}

// Generate a random password and write it to a file — never log it.
Expand Down
73 changes: 73 additions & 0 deletions src/admin/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Shared HTTP response helpers for admin API surfaces.
*
* Single home for the JSON responder, typed error classes, and the
* error-to-Response mapper so the admin API and public API evolve their
* error-handling policy in lockstep (Q-5, Aug 2026 quality audit).
* routes/api/_utils.ts re-exports these for existing route imports.
*/

export function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}

/**
* Typed errors that route handlers can throw to map onto specific
* status codes. Anything else is logged and returned as a generic 500 with
* no internal details, so internal stack-trace-adjacent strings can't leak
* to callers (they're still useful, but admins shouldn't be able to read
* e.g. database errors verbatim).
*/
export class ValidationError extends Error {
override name = "ValidationError";
}
export class NotFoundError extends Error {
override name = "NotFoundError";
}
export class PermissionError extends Error {
override name = "PermissionError";
}

export function serverError(err: unknown): Response {
console.error("[admin api]", err);

if (err instanceof ValidationError) {
return json({ error: err.message || "Bad request" }, 400);
}
if (err instanceof NotFoundError) {
return json({ error: err.message || "Not found" }, 404);
}
if (err instanceof PermissionError) {
return json({ error: "Forbidden" }, 403);
}
// Map Deno's filesystem permission errors to 403 — this restores the
// mapping the old server.ts had (lost in the Fresh 2 rewrite).
if (err instanceof Deno.errors.PermissionDenied) {
return json({ error: "Forbidden" }, 403);
}
if (err instanceof Deno.errors.NotFound) {
return json({ error: "Not found" }, 404);
}

// Generic — never leak err.message to the client.
return json({ error: "Internal server error" }, 500);
}

/**
* Error-to-Response mapper for the public, unauthenticated API surface.
*
* {@link serverError} maps `ValidationError`/`NotFoundError` to 400/404 and
* includes the error's own message — safe for the admin API, where every
* caller is already authenticated, but not for public-api.ts: an anonymous
* caller could trigger a `ValidationError` and read back whatever string an
* internal validation path happened to construct. This mapper always
* returns a single, fixed error status/message regardless of the thrown
* error's type or content, matching the public API's original posture.
*/
export function publicServerError(err: unknown): Response {
console.error("[dune public-api]", err);
return json({ error: "Internal server error" }, 500);
}
Loading
Loading