diff --git a/.env.example b/.env.example
index b9bfcada0e..a4da8c0296 100644
--- a/.env.example
+++ b/.env.example
@@ -59,6 +59,28 @@ RELAY_URL=ws://localhost:3000
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist
+# -----------------------------------------------------------------------------
+# Admin Dashboard (private moderation surface)
+# -----------------------------------------------------------------------------
+# Host name that serves the read-only moderation dashboard and its
+# /api/admin/v1 endpoints. Leave unset to keep the admin surface absent.
+# Setting it requires one of the two authentication variables below.
+# BUZZ_ADMIN_HOST=admin.localhost:3000
+#
+# Option A — Bearer token (recommended for public-facing / OSS deployments):
+# Exactly 64 hex characters (32 bytes); generate with `openssl rand -hex 32`.
+# `just admin` generates a throwaway one per run and prints it.
+# BUZZ_ADMIN_TOKEN=<64 hex characters>
+#
+# Option B — Network-layer auth (for deployments behind a VPN or firewall):
+# Set only when the admin API is already protected at the network layer.
+# The relay logs a WARN on every startup in this mode.
+# Only the exact value "true" is accepted; any other non-empty value fails.
+# BUZZ_ADMIN_INSECURE_NO_AUTH=true
+#
+# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
+# BUZZ_ADMIN_WEB_DIR=./admin-web/dist
+
# Shared Redis-backed admission limits. Defaults shown below; each value must
# be a positive integer.
# BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71a4bbd449..e0bf32cbb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## Unreleased
+
+- **Breaking:** the relay admin moderation API (`/api/admin/v1`) now requires
+ explicit authentication when `BUZZ_ADMIN_HOST` is set. Choose one mode:
+ - **Token mode:** set `BUZZ_ADMIN_TOKEN` to exactly 64 hex characters
+ (`openssl rand -hex 32`). Every API request requires `Authorization: Bearer`.
+ The dashboard prompts for the token on first load.
+ - **Network-layer mode:** set `BUZZ_ADMIN_INSECURE_NO_AUTH=true` (exact value
+ only). Use only when the admin API is already protected by a VPN or private
+ ingress. The relay logs a `WARN` on every startup. The dashboard skips the
+ token prompt and renders directly.
+ - Both set at the same time, or neither, is a startup error.
+ `Host`/`Origin` matching is retained in both modes as defense-in-depth.
+
## v0.5.3
### Desktop and shared changes
diff --git a/Justfile b/Justfile
index 64a1f36daf..98d6a1f614 100644
--- a/Justfile
+++ b/Justfile
@@ -390,7 +390,11 @@ admin: bootstrap _ensure-migrations
pnpm -C admin-web build
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
+ # The relay refuses to start without a token, and never logs one. Mint a
+ # throwaway per run so no dev secret is ever committed or reused.
+ export BUZZ_ADMIN_TOKEN="${BUZZ_ADMIN_TOKEN:-$(openssl rand -hex 32)}"
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
+ echo "Admin token (paste into dashboard prompt): ${BUZZ_ADMIN_TOKEN}"
cargo run -p buzz-relay
# Seed deterministic reports and product feedback for local admin dashboard review
@@ -403,7 +407,7 @@ admin-check: fmt-check
cargo test -p buzz-relay api::admin
cargo test -p buzz-relay router::tests
pnpm -C admin-web check
- pnpm -C admin-web exec playwright test
+ pnpm -C admin-web test:e2e
# Start the relay server in release mode
relay-release: _ensure-migrations
diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx
index f39ecc33c5..6b96c2f83e 100644
--- a/admin-web/src/App.tsx
+++ b/admin-web/src/App.tsx
@@ -6,7 +6,13 @@ import {
useMemo,
useState,
} from "react";
-import { ApiFailure, request } from "./api";
+import {
+ ApiFailure,
+ probeAuthRequired,
+ request,
+ requestObjectUrl,
+} from "./api";
+import { setToken, useToken } from "./token";
import type {
FeedbackDetail,
FeedbackSummary,
@@ -460,7 +466,7 @@ function FeedbackDetailView({ id }: { id: string }) {
{attachments.map((attachment) => (
))}
@@ -491,7 +497,7 @@ function FeedbackDetailView({ id }: { id: string }) {
type FeedbackStatuses = Record;
interface FeedbackAttachment {
- url: string;
+ path: string;
sourceUrl: string;
mimeType: string;
hash: string;
@@ -551,7 +557,7 @@ function feedbackAttachments(
const parsedSize = Number(values.get("size"));
return [
{
- url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
+ path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
sourceUrl: safeUrl,
mimeType,
hash,
@@ -596,7 +602,9 @@ function stripAttachmentMarkdown(
}
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
- const url = attachment.url;
+ const [objectUrl, setObjectUrl] = useState();
+ const [failed, setFailed] = useState(false);
+ const path = attachment.path;
const name =
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
const metadata = [
@@ -607,33 +615,76 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
.filter(Boolean)
.join(" · ");
+ useEffect(() => {
+ // The API requires an Authorization header, so the bytes are fetched here
+ // and handed to the DOM as an object URL revoked on replacement/unmount.
+ let url: string | undefined;
+ let active = true;
+ setObjectUrl(undefined);
+ setFailed(false);
+ requestObjectUrl(path)
+ .then((created) => {
+ if (!active) {
+ URL.revokeObjectURL(created);
+ return;
+ }
+ url = created;
+ setObjectUrl(created);
+ })
+ .catch(() => {
+ if (active) setFailed(true);
+ });
+ return () => {
+ active = false;
+ if (url) URL.revokeObjectURL(url);
+ };
+ }, [path]);
+
+ const detail = failed ? "Could not load attachment" : metadata;
+
if (attachment.mimeType.startsWith("image/")) {
return (
-
-
-
+ {objectUrl ? (
+
+
+
+ ) : (
+
+ {failed ? "Unavailable" : "Loading…"}
+
+ )}
{name}
- {metadata}
+ {detail}
);
}
+ const label = (
+ <>
+
+
+ {name}
+ {detail}
+
+ >
+ );
+
+ // Until the bytes are fetched there is nothing a link could point at: the
+ // API path itself would open unauthenticated in a new tab.
+ if (!objectUrl) return {label}
;
+
return (
-
-
- {name}
- {metadata}
-
+ {label}
);
@@ -797,10 +848,81 @@ function ArrowIcon() {
);
}
+/// The whole dashboard is behind the credential: with no token in session
+/// storage there is nothing worth rendering, and every API call would 401.
+function TokenPrompt({ rejected }: { rejected: boolean }) {
+ const [value, setValue] = useState("");
+ return (
+
+ );
+}
+
export function App() {
const { path } = usePath();
+ const token = useToken();
+ // Distinguishes the first visit from a token the relay just rejected.
+ const [everHadToken, setEverHadToken] = useState(token !== null);
+ useEffect(() => {
+ if (token !== null) setEverHadToken(true);
+ }, [token]);
+
+ // When no token is stored, probe the relay to find out whether it requires
+ // one. In insecure_no_auth mode the probe returns 200 and the dashboard
+ // renders without a credential; in token mode it returns 401 and the prompt
+ // is shown. `null` means the probe is still in flight.
+ const [authRequired, setAuthRequired] = useState(
+ token !== null ? false : null,
+ );
+ useEffect(() => {
+ if (token !== null) {
+ setAuthRequired(false);
+ return;
+ }
+ let active = true;
+ probeAuthRequired().then((required) => {
+ if (active) setAuthRequired(required);
+ });
+ return () => {
+ active = false;
+ };
+ }, [token]);
+
const report = path.match(/^\/reports\/([^/]+)$/);
const feedback = path.match(/^\/feedback\/([^/]+)$/);
+
+ // Probe still in flight — render nothing to avoid a visible flash.
+ if (authRequired === null) return null;
+
+ if (authRequired && !token) return ;
+
const content = report ? (
) : feedback ? (
diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts
index 9e5aa7569f..6f05dfe605 100644
--- a/admin-web/src/api.ts
+++ b/admin-web/src/api.ts
@@ -1,3 +1,5 @@
+import { clearToken, getToken } from "./token";
+
const PREFIX = "/api/admin/v1";
export class ApiFailure extends Error {
@@ -9,11 +11,24 @@ export class ApiFailure extends Error {
}
}
-export async function request(path: string): Promise {
+/// Every admin API call goes through here so the operator token is attached
+/// exactly once and a rejected credential is cleared in exactly one place.
+/// When no token is present the request is sent without an Authorization
+/// header; in insecure_no_auth mode the relay returns 200, while in token
+/// mode it returns 401 and the caller discovers that auth is required.
+async function send(path: string, accept: string): Promise {
+ const token = getToken();
+ const headers: Record = { accept };
+ if (token) headers.authorization = `Bearer ${token}`;
const response = await fetch(`${PREFIX}${path}`, {
credentials: "same-origin",
- headers: { accept: "application/json" },
+ headers,
});
+ if (response.status === 401) {
+ // Idempotent: concurrent 401s collapse into a single re-prompt.
+ clearToken();
+ throw new ApiFailure(401, "The admin token was rejected.");
+ }
if (!response.ok) {
const envelope = await response.json().catch(() => null);
throw new ApiFailure(
@@ -21,5 +36,40 @@ export async function request(path: string): Promise {
envelope?.error?.message ?? `Request failed (${response.status})`,
);
}
+ return response;
+}
+
+export async function request(path: string): Promise {
+ const response = await send(path, "application/json");
return response.json() as Promise;
}
+
+/// Probe whether the relay requires a bearer token. Issues one unauthenticated
+/// request and returns `true` if auth is required (token prompt should be shown)
+/// or `false` if the relay returned 200 (insecure_no_auth mode, no token needed).
+/// Used by the App shell to skip the token prompt for deployments that rely on
+/// network-layer access control.
+export async function probeAuthRequired(): Promise {
+ try {
+ const response = await fetch(`${PREFIX}/reports`, {
+ credentials: "same-origin",
+ headers: { accept: "application/json" },
+ });
+ // 200 → insecure_no_auth mode, no token needed.
+ // Anything else — including 401, 403, 404, 5xx — means auth is required
+ // or the deployment is misconfigured. Show the prompt rather than silently
+ // skipping it.
+ return response.status !== 200;
+ } catch {
+ // Network error — treat as auth-required so the prompt is shown.
+ return true;
+ }
+}
+
+/// Attachments cannot be fetched by ` ` or `` because those
+/// carry no Authorization header. Callers render the object URL and must
+/// revoke it when it is replaced or unmounted.
+export async function requestObjectUrl(path: string): Promise {
+ const response = await send(path, "*/*");
+ return URL.createObjectURL(await response.blob());
+}
diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css
index 93ebceeb14..357fef0a0b 100644
--- a/admin-web/src/styles.css
+++ b/admin-web/src/styles.css
@@ -667,6 +667,26 @@ dd {
color: #9f2424;
}
+.token-prompt input {
+ width: min(22rem, 70vw);
+ padding: 0.65rem 0.9rem;
+ border: 1px solid rgb(35 30 30 / 16%);
+ border-radius: 0.75rem;
+ font: inherit;
+}
+
+.token-prompt button {
+ margin-top: 1rem;
+}
+
+.attachment-placeholder {
+ min-height: 8rem;
+ display: grid;
+ place-content: center;
+ color: rgb(35 30 30 / 48%);
+ font-size: 0.8rem;
+}
+
@media (max-width: 720px) {
.app-header {
width: min(100% - 2rem, 1120px);
diff --git a/admin-web/src/token.ts b/admin-web/src/token.ts
new file mode 100644
index 0000000000..7d5f4ef3c1
--- /dev/null
+++ b/admin-web/src/token.ts
@@ -0,0 +1,56 @@
+import { useSyncExternalStore } from "react";
+
+/// Session-scoped so the credential dies with the browser tab. It is never
+/// written to localStorage or a cookie, and never placed in a URL.
+const STORAGE_KEY = "buzz-admin-token";
+
+const listeners = new Set<() => void>();
+let token = read();
+
+function read(): string | null {
+ try {
+ return sessionStorage.getItem(STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+function publish(next: string | null) {
+ if (token === next) return;
+ token = next;
+ for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function getToken() {
+ return token;
+}
+
+export function setToken(next: string) {
+ try {
+ sessionStorage.setItem(STORAGE_KEY, next);
+ } catch {
+ // The token still works for this page load if storage is blocked.
+ }
+ publish(next);
+}
+
+/// Idempotent so concurrent 401s from parallel requests re-prompt once.
+export function clearToken() {
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // Nothing was stored; the in-memory token is authoritative.
+ }
+ publish(null);
+}
+
+export function useToken() {
+ return useSyncExternalStore(subscribe, getToken, getToken);
+}
diff --git a/admin-web/tests/auth.spec.ts b/admin-web/tests/auth.spec.ts
new file mode 100644
index 0000000000..3a3f9f8aa8
--- /dev/null
+++ b/admin-web/tests/auth.spec.ts
@@ -0,0 +1,448 @@
+import { expect, type Page, test } from "@playwright/test";
+
+const TOKEN =
+ "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+const STORAGE_KEY = "buzz-admin-token";
+
+async function seedToken(page: Page, token = TOKEN) {
+ await page.addInitScript(
+ ([key, value]) => {
+ sessionStorage.setItem(key, value);
+ },
+ [STORAGE_KEY, token],
+ );
+}
+
+/// Records the Authorization header of every admin API call the SPA makes.
+async function recordAuthorization(page: Page, body: unknown = []) {
+ const seen: (string | undefined)[] = [];
+ await page.route("**/api/admin/v1/**", async (route) => {
+ const authorization = route.request().headers().authorization;
+ seen.push(authorization);
+ // Simulate token-mode relay: 401 without a credential, 200 with one.
+ if (!authorization) {
+ await route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: { code: "unauthorized", message: "token required" },
+ }),
+ });
+ } else {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+ }
+ });
+ return seen;
+}
+
+test("the dashboard prompts for a token before any api call", async ({
+ page,
+}) => {
+ const seen = await recordAuthorization(page);
+ await page.goto("/reports");
+ await expect(
+ page.getByRole("heading", { name: "Admin token required" }),
+ ).toBeVisible();
+ // The probe fires unauthenticated (no Authorization header), but no
+ // bearer-credentialed call has been made yet.
+ expect(seen.filter(Boolean)).toHaveLength(0);
+
+ await page.getByPlaceholder("Admin token").fill(TOKEN);
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Open reports" }),
+ ).toBeVisible();
+ expect(seen).toContain(`Bearer ${TOKEN}`);
+});
+
+test("api calls carry the stored token", async ({ page }) => {
+ await seedToken(page);
+ const seen = await recordAuthorization(page);
+ await page.goto("/reports");
+ await expect(
+ page.getByRole("heading", { name: "Open reports" }),
+ ).toBeVisible();
+ expect(seen).toEqual([`Bearer ${TOKEN}`]);
+});
+
+test("the token survives a reload within the session", async ({ page }) => {
+ // Simulate token mode: 401 for unauthenticated, 200 for authenticated.
+ await page.route("**/api/admin/v1/**", (route) => {
+ const authorization = route.request().headers().authorization;
+ if (!authorization) {
+ route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: { code: "unauthorized", message: "token required" },
+ }),
+ });
+ } else {
+ route.fulfill({ contentType: "application/json", body: "[]" });
+ }
+ });
+ await page.goto("/reports");
+ await page.getByPlaceholder("Admin token").fill(TOKEN);
+ await page.getByRole("button", { name: "Continue" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Open reports" }),
+ ).toBeVisible();
+
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: "Open reports" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Admin token required" }),
+ ).toHaveCount(0);
+});
+
+test("a rejected token clears storage and re-prompts once", async ({
+ page,
+}) => {
+ await seedToken(page, "f".repeat(64));
+ await page.route("**/api/admin/v1/**", (route) =>
+ route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: { code: "unauthorized", message: "rejected" },
+ }),
+ }),
+ );
+
+ await page.goto("/reports");
+ const prompt = page.getByRole("heading", { name: "Admin token required" });
+ await expect(prompt).toHaveCount(1);
+ await expect(page.getByText("That token was rejected.")).toBeVisible();
+ expect(
+ await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY),
+ ).toBeNull();
+});
+
+test("attachments are fetched with the token and rendered from blob urls", async ({
+ page,
+}) => {
+ const id = "feedback-with-attachments";
+ const imageHash = "a".repeat(64);
+ const fileHash = "b".repeat(64);
+ const imageUrl = `https://design.buzz.xyz/media/${imageHash}.png`;
+ const fileUrl = `https://design.buzz.xyz/media/${fileHash}.txt`;
+ await seedToken(page);
+
+ const attachmentRequests: { path: string; authorization?: string }[] = [];
+ await page.route(`**/api/admin/v1/feedback/${id}/attachments/**`, (route) => {
+ attachmentRequests.push({
+ path: new URL(route.request().url()).pathname,
+ authorization: route.request().headers().authorization,
+ });
+ route.fulfill({ contentType: "application/octet-stream", body: "bytes" });
+ });
+ await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({
+ id,
+ communityId: "one",
+ communityHost: "design.buzz.xyz",
+ eventId: "31".repeat(32),
+ submitterPubkey: "21".repeat(32),
+ category: "bug",
+ body: "Composer froze.",
+ tags: [
+ [
+ "imeta",
+ `url ${imageUrl}`,
+ "m image/png",
+ `x ${imageHash}`,
+ "filename screenshot.png",
+ ],
+ [
+ "imeta",
+ `url ${fileUrl}`,
+ "m text/plain",
+ `x ${fileHash}`,
+ "filename diagnostics.txt",
+ ],
+ ],
+ eventCreatedAt: "2026-07-17T17:25:00Z",
+ receivedAt: "2026-07-17T17:30:00Z",
+ }),
+ }),
+ );
+
+ await page.goto(`/feedback/${id}`);
+ await expect(
+ page.getByRole("img", { name: "screenshot.png" }),
+ ).toHaveAttribute("src", /^blob:/);
+ await expect(
+ page.getByRole("link", { name: /diagnostics.txt/ }),
+ ).toHaveAttribute("href", /^blob:/);
+
+ expect(attachmentRequests.map((request) => request.path).sort()).toEqual(
+ [
+ `/api/admin/v1/feedback/${id}/attachments/${imageHash}`,
+ `/api/admin/v1/feedback/${id}/attachments/${fileHash}`,
+ ].sort(),
+ );
+ for (const request of attachmentRequests) {
+ expect(request.authorization).toBe(`Bearer ${TOKEN}`);
+ }
+});
+
+interface ObjectUrlLog {
+ created: string[];
+ revoked: string[];
+}
+
+declare global {
+ interface Window {
+ objectUrlLog: ObjectUrlLog;
+ clearedCount: number;
+ }
+}
+
+/// Records every object URL the SPA creates and revokes, so a test can prove a
+/// blob handed to the DOM is released rather than merely replaced.
+async function instrumentObjectUrls(page: Page) {
+ await page.addInitScript(() => {
+ const log: ObjectUrlLog = { created: [], revoked: [] };
+ window.objectUrlLog = log;
+ const create = URL.createObjectURL.bind(URL);
+ const revoke = URL.revokeObjectURL.bind(URL);
+ URL.createObjectURL = (source: Blob | MediaSource) => {
+ const url = create(source);
+ log.created.push(url);
+ return url;
+ };
+ URL.revokeObjectURL = (url: string) => {
+ log.revoked.push(url);
+ revoke(url);
+ };
+ });
+}
+
+const FEEDBACK_ID = "feedback-with-attachments";
+const IMAGE_HASH = "a".repeat(64);
+const FILE_HASH = "b".repeat(64);
+
+/// A feedback detail carrying one image and one non-image attachment.
+async function routeFeedbackDetail(page: Page) {
+ const host = "design.buzz.xyz";
+ await page.route(`**/api/admin/v1/feedback?**`, (route) =>
+ route.fulfill({ contentType: "application/json", body: "[]" }),
+ );
+ await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ID}`, (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({
+ id: FEEDBACK_ID,
+ communityId: "one",
+ communityHost: host,
+ eventId: "31".repeat(32),
+ submitterPubkey: "21".repeat(32),
+ category: "bug",
+ body: "Composer froze.",
+ tags: [
+ [
+ "imeta",
+ `url https://${host}/media/${IMAGE_HASH}.png`,
+ "m image/png",
+ `x ${IMAGE_HASH}`,
+ "filename screenshot.png",
+ ],
+ [
+ "imeta",
+ `url https://${host}/media/${FILE_HASH}.txt`,
+ "m text/plain",
+ `x ${FILE_HASH}`,
+ "filename diagnostics.txt",
+ ],
+ ],
+ eventCreatedAt: "2026-07-17T17:25:00Z",
+ receivedAt: "2026-07-17T17:30:00Z",
+ }),
+ }),
+ );
+}
+
+test("attachment object urls are revoked when the view is left", async ({
+ page,
+}) => {
+ await seedToken(page);
+ await instrumentObjectUrls(page);
+ await routeFeedbackDetail(page);
+ await page.route(
+ `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
+ (route) =>
+ route.fulfill({ contentType: "application/octet-stream", body: "bytes" }),
+ );
+
+ await page.goto(`/feedback/${FEEDBACK_ID}`);
+ const imageUrl = await page
+ .getByRole("img", { name: "screenshot.png" })
+ .getAttribute("src");
+ const fileUrl = await page
+ .getByRole("link", { name: /diagnostics.txt/ })
+ .getAttribute("href");
+ expect(imageUrl).toMatch(/^blob:/);
+ expect(fileUrl).toMatch(/^blob:/);
+ expect(await page.evaluate(() => window.objectUrlLog.revoked)).toEqual([]);
+
+ await page.getByRole("link", { name: "Back to feedback" }).click();
+ await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
+
+ await expect
+ .poll(() => page.evaluate(() => window.objectUrlLog.revoked))
+ .toEqual(expect.arrayContaining([imageUrl, fileUrl]));
+});
+
+test("an attachment that arrives after the view is left is revoked immediately", async ({
+ page,
+}) => {
+ await seedToken(page);
+ await instrumentObjectUrls(page);
+ await routeFeedbackDetail(page);
+ let release = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ await page.route(
+ `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
+ async (route) => {
+ await held;
+ await route.fulfill({
+ contentType: "application/octet-stream",
+ body: "bytes",
+ });
+ },
+ );
+
+ await page.goto(`/feedback/${FEEDBACK_ID}`);
+ // Both fetches are held, so no blob exists yet.
+ await expect(page.getByText("Loading…")).toBeVisible();
+ expect(await page.evaluate(() => window.objectUrlLog.created)).toEqual([]);
+
+ // Leave before either fetch resolves, then let both complete.
+ await page.getByRole("link", { name: "Back to feedback" }).click();
+ await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
+ release();
+
+ await expect
+ .poll(() => page.evaluate(() => window.objectUrlLog.revoked.length))
+ .toBe(2);
+ const log = await page.evaluate(() => window.objectUrlLog);
+ expect(log.revoked.sort()).toEqual(log.created.sort());
+ // Nothing was ever handed to the DOM: the blobs outlived their view.
+ await expect(page.getByRole("img", { name: "screenshot.png" })).toHaveCount(
+ 0,
+ );
+});
+
+test("concurrent rejected requests re-prompt exactly once", async ({
+ page,
+}) => {
+ await seedToken(page, "f".repeat(64));
+ await routeFeedbackDetail(page);
+ // Counts how many rejections reached the centralized clearing path, so the
+ // test can distinguish "two 401s collapsed into one prompt" from "only one
+ // request ever failed".
+ await page.addInitScript(() => {
+ let cleared = 0;
+ const remove = sessionStorage.removeItem.bind(sessionStorage);
+ sessionStorage.removeItem = (key: string) => {
+ cleared += 1;
+ remove(key);
+ };
+ Object.defineProperty(window, "clearedCount", { get: () => cleared });
+ });
+
+ // Both attachment requests are held until the second arrives, so the two
+ // 401s are in flight at the same time.
+ let secondArrived = () => {};
+ const bothInFlight = new Promise((resolve) => {
+ secondArrived = resolve;
+ });
+ let pending = 2;
+ await page.route(
+ `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`,
+ async (route) => {
+ pending -= 1;
+ if (pending === 0) secondArrived();
+ await bothInFlight;
+ await route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: { code: "unauthorized", message: "rejected" },
+ }),
+ });
+ },
+ );
+
+ await page.goto(`/feedback/${FEEDBACK_ID}`);
+ await expect(
+ page.getByRole("heading", { name: "Admin token required" }),
+ ).toHaveCount(1);
+ await expect(page.getByText("That token was rejected.")).toBeVisible();
+ expect(
+ await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY),
+ ).toBeNull();
+ expect(pending).toBe(0);
+ await expect
+ .poll(() => page.evaluate(() => window.clearedCount))
+ .toBeGreaterThanOrEqual(2);
+ await expect(
+ page.getByRole("heading", { name: "Admin token required" }),
+ ).toHaveCount(1);
+});
+
+test("probe: insecure_no_auth mode skips the token prompt when probe returns 200", async ({
+ page,
+}) => {
+ // No token in storage. The probe to /api/admin/v1/reports returns 200,
+ // indicating the relay runs in insecure_no_auth mode. The dashboard must
+ // render directly without showing the token prompt.
+ await page.route("**/api/admin/v1/reports**", (route) =>
+ route.fulfill({ contentType: "application/json", body: "[]" }),
+ );
+
+ await page.goto("/reports");
+
+ await expect(
+ page.getByRole("heading", { name: "Open reports" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Admin token required" }),
+ ).toHaveCount(0);
+});
+
+test("probe: token mode shows the prompt when probe returns 401", async ({
+ page,
+}) => {
+ // No token in storage. The probe to /api/admin/v1/reports returns 401,
+ // indicating the relay requires a bearer token. The prompt must be shown.
+ await page.route("**/api/admin/v1/**", (route) =>
+ route.fulfill({
+ status: 401,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: { code: "unauthorized", message: "token required" },
+ }),
+ }),
+ );
+
+ await page.goto("/reports");
+
+ await expect(
+ page.getByRole("heading", { name: "Admin token required" }),
+ ).toBeVisible();
+ // The prompt must not say "rejected" on a fresh first visit.
+ await expect(page.getByText("That token was rejected.")).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Open reports" })).toHaveCount(
+ 0,
+ );
+});
diff --git a/admin-web/tests/csp.spec.ts b/admin-web/tests/csp.spec.ts
new file mode 100644
index 0000000000..343862ad33
--- /dev/null
+++ b/admin-web/tests/csp.spec.ts
@@ -0,0 +1,109 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { expect, test } from "@playwright/test";
+
+const ROUTER_RS = fileURLToPath(
+ new URL("../../crates/buzz-relay/src/router.rs", import.meta.url),
+);
+
+/// The exact policy the relay serves on admin SPA documents. Read from the
+/// relay source rather than copied, so this test can never pass against a
+/// policy operators do not actually get. The preview server used here serves
+/// no CSP of its own, so it is injected below.
+function adminCsp() {
+ const source = readFileSync(ROUTER_RS, "utf8");
+ const match = source.match(/const ADMIN_CSP: &str = "([^"]+)";/);
+ if (!match) throw new Error(`ADMIN_CSP not found in ${ROUTER_RS}`);
+ return match[1];
+}
+
+const TOKEN =
+ "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
+
+test("the relay admin csp does not break the built dashboard", async ({
+ page,
+}) => {
+ const csp = adminCsp();
+ expect(csp).toContain("frame-ancestors 'none'");
+ expect(csp).not.toContain("unsafe-inline");
+
+ await page.addInitScript((token) => {
+ sessionStorage.setItem("buzz-admin-token", token);
+ }, TOKEN);
+ await page.route("**/api/admin/v1/**", (route) =>
+ route.fulfill({ contentType: "application/json", body: "[]" }),
+ );
+ // Only the document request: the API call to /reports carries a query string.
+ await page.route(
+ (url) => url.pathname === "/reports" && url.search === "",
+ async (route) => {
+ const response = await route.fetch();
+ await route.fulfill({
+ response,
+ headers: { ...response.headers(), "content-security-policy": csp },
+ });
+ },
+ );
+
+ const violations: string[] = [];
+ page.on("console", (message) => {
+ if (message.text().includes("Content Security Policy"))
+ violations.push(message.text());
+ });
+
+ await page.goto("/reports");
+
+ // Rendering at all proves the bundle's script and stylesheet loaded, and the
+ // empty state proves the fetch to /api/admin/v1 survived `connect-src 'self'`.
+ await expect(
+ page.getByRole("heading", { name: "Open reports" }),
+ ).toBeVisible();
+ await expect(page.getByText("No records.")).toBeVisible();
+ expect(violations).toEqual([]);
+});
+
+test("the linked favicon loads under the admin csp", async ({ page }) => {
+ const csp = adminCsp();
+
+ await page.route(
+ (url) => url.pathname === "/" && url.search === "",
+ async (route) => {
+ const response = await route.fetch();
+ await route.fulfill({
+ response,
+ headers: { ...response.headers(), "content-security-policy": csp },
+ });
+ },
+ );
+
+ const violations: string[] = [];
+ page.on("console", (message) => {
+ if (message.text().includes("Content Security Policy"))
+ violations.push(message.text());
+ });
+
+ await page.goto("/");
+
+ const href = await page.locator("link[rel=icon]").getAttribute("href");
+ expect(href).toBe("/favicon.svg");
+
+ // Headless Chromium never issues the ` ` request itself, so
+ // load the same file the same way the policy sees it: an image fetch under
+ // `img-src 'self'`. A blocked fetch rejects `decode()`; a successful one
+ // proves the icon renders, and that the SVG's own inline