feat: add read-only deployment moderation dashboard (#1999)

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-17 12:50:06 -07:00
committed by GitHub
co-authored by npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 0e03836018
commit 68e670e001
30 changed files with 3859 additions and 21 deletions
+1
View File
@@ -1,6 +1,7 @@
# Build artifacts
/target/
/dist/
/admin-web/dist/
# lefthook-generated hook scripts (machine-specific)
.hooks/
+9 -4
View File
@@ -105,9 +105,11 @@ RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches/ patches/
COPY web/package.json web/
RUN pnpm install --frozen-lockfile --filter buzz-web
COPY admin-web/package.json admin-web/
RUN pnpm install --frozen-lockfile --filter buzz-web --filter buzz-admin-web
COPY web/ web/
RUN pnpm -C web build
COPY admin-web/ admin-web/
RUN pnpm -C web build && pnpm -C admin-web build
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
FROM debian:${DEBIAN_VERSION}-slim AS runtime
@@ -137,10 +139,13 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-rela
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
COPY --from=web-builder /build/web/dist /srv/buzz/web
COPY --from=web-builder /build/admin-web/dist /srv/buzz/admin-web
# The invite landing page is always served from the bundled web UI. Repository
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in.
ENV BUZZ_WEB_DIR=/srv/buzz/web
# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. The
# admin bundle is inert until BUZZ_ADMIN_HOST is configured.
ENV BUZZ_WEB_DIR=/srv/buzz/web \
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
EXPOSE 3000 8080 9102
+24
View File
@@ -363,6 +363,30 @@ relay-web: bootstrap _ensure-migrations
pnpm -C web build
BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay
# Build and run the private read-only admin dashboard
admin: bootstrap _ensure-migrations
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
[[ -d node_modules ]] || pnpm install
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}"
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
cargo run -p buzz-relay
# Seed deterministic reports and product feedback for local admin dashboard review
admin-seed: _ensure-migrations
./scripts/seed-admin-dashboard.sh
# Run focused relay and browser checks for the read-only admin dashboard
admin-check: fmt-check
cargo check -p buzz-relay --all-targets
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
# Start the relay server in release mode
relay-release: _ensure-migrations
cargo run -p buzz-relay --release
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Buzz admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "buzz-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"lint": "biome lint .",
"check": "biome check . && pnpm typecheck && pnpm test",
"format": "biome format --write .",
"test": "vitest run src --passWithNoTests",
"test:e2e": "pnpm build && playwright test"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"jsdom": "^27.3.0",
"typescript": "~6.0.0",
"vitest": "^4.1.1"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
webServer: {
command: "pnpm exec vite preview --host 127.0.0.1 --port 4174",
url: "http://127.0.0.1:4174",
reuseExistingServer: true,
},
use: { baseURL: "http://127.0.0.1:4174" },
});
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 466 309">
<style>
.mark { fill: #231e1e; }
@media (prefers-color-scheme: dark) {
.mark { fill: #d7d72e; }
}
</style>
<defs>
<mask id="bee-mask">
<circle cx="91.7" cy="154.5" r="91.7" fill="white"/>
<circle cx="374.3" cy="154.5" r="91.7" fill="white"/>
<rect x="128" y="0" width="210" height="309" rx="34" fill="white"/>
<circle cx="193.3" cy="84.4" r="27" fill="black"/>
<circle cx="276" cy="84.4" r="27" fill="black"/>
<rect x="166.3" y="157.2" width="136.9" height="38.3" rx="5" fill="black"/>
<rect x="166.9" y="235.1" width="136.2" height="37.6" rx="5" fill="black"/>
</mask>
</defs>
<rect class="mark" width="466" height="309" mask="url(#bee-mask)"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

+798
View File
@@ -0,0 +1,798 @@
import {
type ChangeEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ApiFailure, request } from "./api";
import type { FeedbackDetail, FeedbackSummary, Report } from "./types";
import { useResource } from "./useResource";
function usePath() {
const [path, setPath] = useState(location.pathname);
useEffect(() => {
const update = () => setPath(location.pathname);
addEventListener("popstate", update);
return () => removeEventListener("popstate", update);
}, []);
const navigate = useCallback((url: string) => {
history.pushState(null, "", url);
dispatchEvent(new PopStateEvent("popstate"));
}, []);
return { path, navigate };
}
function Link({
href,
className,
activeWhenNested = false,
children,
}: {
href: string;
className?: string;
activeWhenNested?: boolean;
children: ReactNode;
}) {
const { path, navigate } = usePath();
const active =
path === href || (activeWhenNested && path.startsWith(`${href}/`));
return (
<a
href={href}
className={className}
aria-current={active ? "page" : undefined}
onClick={(event) => {
if (!event.metaKey && !event.ctrlKey) {
event.preventDefault();
navigate(href);
}
}}
>
{children}
</a>
);
}
function StateView<T>({
resource,
children,
}: {
resource: ReturnType<typeof useResource<T>>;
children: (data: T) => ReactNode;
}) {
if (resource.loading && !resource.data)
return <div className="state">Loading</div>;
if (resource.error && !resource.data) {
const forbidden =
resource.error instanceof ApiFailure && resource.error.status === 403;
return (
<div className="state error" role="alert">
<h2>{forbidden ? "Access denied" : "Could not load data"}</h2>
<p>{resource.error.message}</p>
<button type="button" onClick={resource.refetch}>
Retry
</button>
</div>
);
}
return resource.data ? children(resource.data) : null;
}
function Reports() {
const resource = useResource(
() => request<Report[]>("/reports?status=open&limit=100"),
"reports",
);
return (
<Page
eyebrow="Moderation"
title="Open reports"
description="Review reports across every Buzz community."
>
<StateView resource={resource}>
{(reports) =>
reports.length ? (
<div className="cards">
{reports.map((report) => (
<Link
key={report.id}
href={`/reports/${report.id}`}
className="card-link"
>
<article className="record-card">
<span className="record-icon report-icon">
<ReportIcon />
</span>
<div className="record-primary">
<span className="tag">{report.reportType}</span>
<strong>{report.communityHost}</strong>
<code>
{report.targetKind}: {short(report.target)}
</code>
</div>
<div className="record-date">
<span>Submitted</span>
<time>{date(report.createdAt)}</time>
</div>
<ArrowIcon />
</article>
</Link>
))}
</div>
) : (
<Empty />
)
}
</StateView>
</Page>
);
}
function ReportDetail({ id }: { id: string }) {
const resource = useResource(() => request<Report>(`/reports/${id}`), id);
return (
<Page
eyebrow="Moderation"
title="Report detail"
description="The full report as submitted to the relay."
back="/reports"
>
<StateView resource={resource}>
{(report) => (
<article className="detail">
<div className="detail-heading">
<span className="record-icon report-icon">
<ReportIcon />
</span>
<div>
<span className="tag">{report.reportType}</span>
<h2>{report.communityHost}</h2>
</div>
</div>
<dl>
<dt>Status</dt>
<dd>
<span className="status">{report.status}</span>
</dd>
<dt>Reporter</dt>
<dd>
<code>{report.reporterPubkey}</code>
</dd>
<dt>Target</dt>
<dd>
<code>{report.target}</code>
</dd>
<dt>Note</dt>
<dd className="sensitive">
{report.note ?? "No note provided."}
</dd>
</dl>
</article>
)}
</StateView>
</Page>
);
}
function FeedbackList() {
const resource = useResource(
() => request<FeedbackSummary[]>("/feedback"),
"feedback",
);
const [query, setQuery] = useState("");
const [community, setCommunity] = useState("all");
const [timeRange, setTimeRange] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [statuses, setStatuses] = useState(loadFeedbackStatuses);
const updateStatus = (id: string, event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
setStatuses((current) => {
const next = {
...current,
[id]: checked,
};
saveFeedbackStatuses(next);
return next;
});
};
return (
<Page
eyebrow="Product"
title="Feedback"
description="Recent product feedback from across Buzz."
>
<StateView resource={resource}>
{(items) => {
if (!items.length) return <Empty />;
return (
<FeedbackResults
items={items}
query={query}
community={community}
timeRange={timeRange}
statusFilter={statusFilter}
statuses={statuses}
>
{({ communities, filtered }) => (
<>
<div className="feedback-filters">
<label className="search-field">
<span>Search feedback</span>
<div>
<SearchIcon />
<input
type="search"
placeholder="Search feedback"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</label>
<label>
<span>Community</span>
<select
value={community}
onChange={(event) => setCommunity(event.target.value)}
>
<option value="all">All communities</option>
{communities.map((host) => (
<option key={host} value={host}>
{host}
</option>
))}
</select>
</label>
<label>
<span>Received</span>
<select
value={timeRange}
onChange={(event) => setTimeRange(event.target.value)}
>
<option value="all">Any time</option>
<option value="day">Last 24 hours</option>
<option value="week">Last 7 days</option>
<option value="month">Last 30 days</option>
</select>
</label>
<label>
<span>Status</span>
<select
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value)
}
>
<option value="all">Any status</option>
<option value="pending">Needs action</option>
<option value="acted-on">Acted on</option>
</select>
</label>
</div>
<p className="result-count" aria-live="polite">
{filtered.length} of {items.length} submissions
</p>
{filtered.length ? (
<div className="cards">
{filtered.map((item) => (
<article
key={item.id}
className="record-card feedback-card feedback-record"
>
<Link
href={`/feedback/${item.id}`}
className="feedback-main-link"
>
<span className="record-icon feedback-icon">
<CategoryIcon category={item.category} />
</span>
<div className="record-primary">
<CategoryTag category={item.category} />
<strong>{item.bodySummary}</strong>
<span className="record-provenance">
{item.communityHost}
<code>{short(item.submitterPubkey)}</code>
</span>
</div>
</Link>
<label className="feedback-status">
<input
type="checkbox"
checked={statuses[item.id] ?? false}
onChange={(event) => updateStatus(item.id, event)}
/>
Acted on
<span className="visually-hidden">
feedback from {item.communityHost}
</span>
</label>
<div className="record-date">
<span>Received</span>
<time>{date(item.receivedAt)}</time>
</div>
<Link
href={`/feedback/${item.id}`}
className="record-open-link"
>
<span className="visually-hidden">
Open feedback from {item.communityHost}
</span>
<ArrowIcon />
</Link>
</article>
))}
</div>
) : (
<div className="state">No matching feedback.</div>
)}
</>
)}
</FeedbackResults>
);
}}
</StateView>
</Page>
);
}
function FeedbackResults({
items,
query,
community,
timeRange,
statusFilter,
statuses,
children,
}: {
items: FeedbackSummary[];
query: string;
community: string;
timeRange: string;
statusFilter: string;
statuses: FeedbackStatuses;
children: (results: {
communities: string[];
filtered: FeedbackSummary[];
}) => ReactNode;
}) {
const results = useMemo(() => {
const communities = [...new Set(items.map((item) => item.communityHost))]
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
const normalizedQuery = query.trim().toLocaleLowerCase();
const after = timeRangeStart(timeRange);
const filtered = items.filter((item) => {
if (community !== "all" && item.communityHost !== community) return false;
if (statusFilter === "pending" && statuses[item.id]) return false;
if (statusFilter === "acted-on" && !statuses[item.id]) return false;
if (after !== undefined) {
const receivedAt = new Date(item.receivedAt).valueOf();
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
}
if (!normalizedQuery) return true;
return [
item.bodySummary,
item.communityHost,
item.category ?? "uncategorized",
item.submitterPubkey,
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
});
return { communities, filtered };
}, [items, query, community, timeRange, statusFilter, statuses]);
return children(results);
}
function FeedbackDetailView({ id }: { id: string }) {
const resource = useResource(
() => request<FeedbackDetail>(`/feedback/${id}`),
id,
);
return (
<Page
eyebrow="Product"
title="Feedback detail"
description="The complete feedback submission and its source."
back="/feedback"
backLabel="Back to feedback"
>
<StateView resource={resource}>
{(feedback) => {
const attachments = feedbackAttachments(
feedback.tags,
feedback.communityHost,
);
const body = stripAttachmentMarkdown(feedback.body, attachments);
return (
<article className="detail">
<div className="detail-heading">
<span className="record-icon feedback-icon">
<CategoryIcon category={feedback.category} />
</span>
<div>
<CategoryTag category={feedback.category} />
<h2>{feedback.communityHost}</h2>
</div>
</div>
<dl>
<dt>Feedback</dt>
<dd className="sensitive feedback-body">{body}</dd>
{attachments.length ? (
<>
<dt>Attachments</dt>
<dd className="attachments">
{attachments.map((attachment) => (
<Attachment
key={`${attachment.hash}-${attachment.url}`}
attachment={attachment}
/>
))}
</dd>
</>
) : null}
<dt>Submitted by</dt>
<dd>
<code>{feedback.submitterPubkey}</code>
</dd>
<dt>Event</dt>
<dd>
<code>{feedback.eventId}</code>
</dd>
<dt>Created</dt>
<dd>{date(feedback.eventCreatedAt)}</dd>
<dt>Received</dt>
<dd>{date(feedback.receivedAt)}</dd>
</dl>
</article>
);
}}
</StateView>
</Page>
);
}
type FeedbackStatuses = Record<string, boolean>;
interface FeedbackAttachment {
url: string;
mimeType: string;
hash: string;
size?: number;
dimensions?: string;
filename?: string;
}
const FEEDBACK_STATUS_KEY = "buzz-admin-feedback-status";
function loadFeedbackStatuses(): FeedbackStatuses {
try {
const stored = localStorage.getItem(FEEDBACK_STATUS_KEY);
return stored ? (JSON.parse(stored) as FeedbackStatuses) : {};
} catch {
return {};
}
}
function saveFeedbackStatuses(statuses: FeedbackStatuses) {
try {
localStorage.setItem(FEEDBACK_STATUS_KEY, JSON.stringify(statuses));
} catch {
// The controls remain useful for the current session if storage is blocked.
}
}
function timeRangeStart(range: string) {
const durations: Record<string, number> = {
day: 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000,
month: 30 * 24 * 60 * 60 * 1000,
};
const duration = durations[range];
return duration ? Date.now() - duration : undefined;
}
function feedbackAttachments(
tags: string[][],
communityHost: string,
): FeedbackAttachment[] {
return tags.flatMap((tag) => {
if (tag[0] !== "imeta") return [];
const values = new Map<string, string>();
for (const entry of tag.slice(1)) {
const separator = entry.indexOf(" ");
if (separator > 0) {
values.set(entry.slice(0, separator), entry.slice(separator + 1));
}
}
const url = values.get("url");
const mimeType = values.get("m");
const hash = values.get("x");
const safeUrl = url && safeAttachmentUrl(url, communityHost);
if (!safeUrl || !mimeType || !hash) return [];
const parsedSize = Number(values.get("size"));
return [
{
url: safeUrl,
mimeType,
hash,
size:
Number.isFinite(parsedSize) && parsedSize > 0
? parsedSize
: undefined,
dimensions: values.get("dim"),
filename: values.get("filename"),
},
];
});
}
function safeAttachmentUrl(value: string, communityHost: string) {
try {
const url = new URL(value, `${location.protocol}//${communityHost}`);
return ["http:", "https:"].includes(url.protocol) &&
url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
url.pathname.startsWith("/media/")
? url.href
: undefined;
} catch {
return undefined;
}
}
function stripAttachmentMarkdown(
body: string,
attachments?: FeedbackAttachment[],
) {
const knownUrls = attachments
? new Set(attachments.map((attachment) => attachment.url))
: undefined;
return body
.replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
return isMedia ? "" : match;
})
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
const url = attachment.url;
const name =
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
const metadata = [
attachment.mimeType,
attachment.dimensions,
attachment.size ? formatBytes(attachment.size) : undefined,
]
.filter(Boolean)
.join(" · ");
if (attachment.mimeType.startsWith("image/")) {
return (
<figure className="image-attachment">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={name} loading="lazy" />
</a>
<figcaption>
<span>{name}</span>
<small>{metadata}</small>
</figcaption>
</figure>
);
}
return (
<a
className="file-attachment"
href={url}
target="_blank"
rel="noreferrer"
download={name}
>
<FileIcon />
<span>
<strong>{name}</strong>
<small>{metadata}</small>
</span>
<ArrowIcon />
</a>
);
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function Page({
eyebrow,
title,
description,
back,
backLabel,
children,
}: {
eyebrow: string;
title: string;
description: string;
back?: string;
backLabel?: string;
children: ReactNode;
}) {
return (
<section>
<header className="page-title">
{back ? (
<Link href={back} className="back-link">
<ArrowIcon /> {backLabel ?? "Back to reports"}
</Link>
) : null}
<p>{eyebrow}</p>
<h1>{title}</h1>
<span>{description}</span>
</header>
{children}
</section>
);
}
function Empty() {
return <div className="state">No records.</div>;
}
function short(value: string) {
return value.length > 20 ? `${value.slice(0, 10)}${value.slice(-8)}` : value;
}
function date(value: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? "Unknown date"
: parsed.toLocaleString();
}
function BuzzMark() {
return (
<svg viewBox="0 0 466 309" aria-hidden="true">
<path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
<path
fillRule="evenodd"
d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
clipRule="evenodd"
/>
</svg>
);
}
function ReportIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
<path d="M12 7.5v5M12 16.5h.01" />
</svg>
);
}
function FeedbackIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5.5h14v10H9l-4 3v-13Z" />
<path d="M8.5 9h7M8.5 12h4.5" />
</svg>
);
}
function CategoryTag({ category }: { category?: string }) {
const labels: Record<string, string> = {
bug: "Bug",
praise: "Praise",
"needs-work": "Needs work",
};
return (
<span className="tag">
<CategoryIcon category={category} />
{category ? (labels[category] ?? category) : "Uncategorized"}
</span>
);
}
function CategoryIcon({ category }: { category?: string }) {
if (category === "bug") return <BugIcon />;
if (category === "praise") return <ThumbsUpIcon />;
if (category === "needs-work") return <WrenchIcon />;
return <FeedbackIcon />;
}
function BugIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>
);
}
function ThumbsUpIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
<path d="M7 10v12" />
</svg>
);
}
function WrenchIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4 4" />
</svg>
);
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 3h8l4 4v14H6V3Z" />
<path d="M14 3v5h4M9 13h6M9 17h4" />
</svg>
);
}
function ArrowIcon() {
return (
<svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
);
}
export function App() {
const { path } = usePath();
const report = path.match(/^\/reports\/([^/]+)$/);
const feedback = path.match(/^\/feedback\/([^/]+)$/);
const content = report ? (
<ReportDetail id={report[1]} />
) : feedback ? (
<FeedbackDetailView id={feedback[1]} />
) : path === "/feedback" ? (
<FeedbackList />
) : (
<Reports />
);
return (
<div className="app">
<header className="app-header">
<Link href="/reports" className="brand">
<span className="brand-mark">
<BuzzMark />
</span>
<span>
Buzz <b>Admin</b>
</span>
</Link>
<nav>
<Link href="/reports" className="nav-link" activeWhenNested>
<ReportIcon /> Reports
</Link>
<Link href="/feedback" className="nav-link" activeWhenNested>
<FeedbackIcon /> Feedback
</Link>
</nav>
</header>
<main>{content}</main>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
const PREFIX = "/api/admin/v1";
export class ApiFailure extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export async function request<T>(path: string): Promise<T> {
const response = await fetch(`${PREFIX}${path}`, {
credentials: "same-origin",
headers: { accept: "application/json" },
});
if (!response.ok) {
const envelope = await response.json().catch(() => null);
throw new ApiFailure(
response.status,
envelope?.error?.message ?? `Request failed (${response.status})`,
);
}
return response.json() as Promise<T>;
}
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+702
View File
@@ -0,0 +1,702 @@
:root {
font-family:
"Helvetica Neue", Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
color: #231e1e;
background: #d7e7f6;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
input,
select {
font: inherit;
}
button {
color: white;
background: #231e1e;
border: 0;
border-radius: 999px;
padding: 0.75rem 1.25rem;
cursor: pointer;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #d7d72e 0%, #dfe379 24%, #d7e7f6 78%);
}
.app-header {
width: min(1120px, calc(100% - 3rem));
margin: 0 auto;
padding: 1.5rem 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.04em;
}
.brand b {
font-weight: 400;
color: rgb(35 30 30 / 60%);
}
.brand-mark {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: 0.7rem;
color: #d7d72e;
background: #231e1e;
}
.brand-mark svg {
width: 1.7rem;
fill: currentColor;
}
nav {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 999px;
background: rgb(255 255 255 / 42%);
backdrop-filter: blur(12px);
}
.nav-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1rem;
border-radius: 999px;
color: rgb(35 30 30 / 60%);
font-size: 0.9rem;
letter-spacing: -0.035em;
transition:
color 160ms ease,
background 160ms ease;
}
.nav-link svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-link:hover {
color: #231e1e;
}
.nav-link[aria-current="page"] {
color: #231e1e;
background: white;
box-shadow: 0 0.2rem 1rem rgb(35 30 30 / 8%);
}
main {
width: min(1120px, calc(100% - 3rem));
margin: 0 auto;
padding: 4.5rem 0 7rem;
}
.page-title {
max-width: 52rem;
margin-bottom: 3rem;
}
.page-title > p {
margin: 0 0 1rem;
color: rgb(35 30 30 / 55%);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(3rem, 7vw, 5.5rem);
font-weight: 400;
line-height: 0.92;
letter-spacing: -0.065em;
}
.page-title > span {
display: block;
margin-top: 1.5rem;
color: rgb(35 30 30 / 62%);
font-size: 1.05rem;
line-height: 1.5;
letter-spacing: -0.035em;
}
.back-link {
width: fit-content;
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 2.25rem;
color: rgb(35 30 30 / 62%);
font-size: 0.9rem;
letter-spacing: -0.035em;
}
.cards {
display: grid;
gap: 0.8rem;
}
.feedback-filters {
display: grid;
grid-template-columns: minmax(14rem, 1fr) repeat(3, auto);
gap: 0.75rem;
align-items: end;
margin-bottom: 0.75rem;
padding: 1rem;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1.25rem;
background: rgb(255 255 255 / 45%);
backdrop-filter: blur(12px);
}
.feedback-filters label {
display: grid;
gap: 0.45rem;
}
.feedback-filters label > span {
color: rgb(35 30 30 / 48%);
font-size: 0.7rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.feedback-filters input,
.feedback-filters select {
min-height: 2.75rem;
border: 1px solid rgb(35 30 30 / 12%);
border-radius: 999px;
outline: none;
color: #231e1e;
background: white;
}
.feedback-filters input:focus,
.feedback-filters select:focus {
border-color: #231e1e;
box-shadow: 0 0 0 3px rgb(35 30 30 / 10%);
}
.feedback-filters select {
padding: 0 2.25rem 0 1rem;
}
.search-field > div {
position: relative;
}
.search-field input {
width: 100%;
padding: 0 1rem 0 2.6rem;
}
.search-field svg {
position: absolute;
z-index: 1;
top: 50%;
left: 1rem;
width: 1rem;
height: 1rem;
transform: translateY(-50%);
fill: none;
stroke: rgb(35 30 30 / 45%);
stroke-width: 1.7;
stroke-linecap: round;
}
.result-count {
margin: 0 0 0.75rem;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
}
.card-link {
display: block;
border-radius: 1.6rem;
}
.record-card,
.detail,
.state {
background: white;
border-radius: 1.6rem;
box-shadow: 0 1rem 4rem rgb(35 30 30 / 6%);
}
.record-card {
min-height: 7.2rem;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 1.4rem;
padding: 1.2rem 1.5rem;
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.feedback-record {
grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.feedback-main-link {
min-width: 0;
display: flex;
align-items: center;
gap: 1.4rem;
}
.record-open-link:hover {
text-decoration: underline;
text-underline-offset: 0.2rem;
}
.feedback-status {
min-width: 5rem;
display: flex;
align-items: center;
gap: 0.4rem;
color: rgb(35 30 30 / 62%);
font-size: 0.78rem;
cursor: pointer;
}
.feedback-status input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #231e1e;
}
.record-open-link {
display: grid;
place-items: center;
padding: 0.5rem;
border-radius: 999px;
color: rgb(35 30 30 / 30%);
}
.card-link:hover .record-card {
transform: translateY(-2px);
box-shadow: 0 1.4rem 4.5rem rgb(35 30 30 / 12%);
}
.record-icon {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 999px;
color: #231e1e;
}
.report-icon {
background: #d7d72e;
}
.feedback-icon {
background: #d7e7f6;
}
.record-icon svg {
width: 1.35rem;
height: 1.35rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.record-primary {
min-width: 0;
display: grid;
justify-items: start;
gap: 0.35rem;
}
.tag,
.status {
display: inline-flex;
align-items: center;
border-radius: 999px;
background: #f2f2ed;
padding: 0.25rem 0.55rem;
color: rgb(35 30 30 / 65%);
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tag {
gap: 0.3rem;
}
.tag svg {
width: 0.78rem;
height: 0.78rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.record-primary strong {
max-width: 42rem;
overflow: hidden;
color: #231e1e;
font-size: 1rem;
font-weight: 400;
line-height: 1.35;
letter-spacing: -0.04em;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-card .record-primary strong {
display: -webkit-box;
overflow: hidden;
line-height: 1.35;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.record-provenance {
display: flex;
align-items: center;
gap: 0.65rem;
color: rgb(35 30 30 / 55%);
font-size: 0.78rem;
}
code {
color: rgb(35 30 30 / 50%);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
word-break: break-all;
}
.record-provenance code {
padding-left: 0.65rem;
border-left: 1px solid rgb(35 30 30 / 14%);
}
.record-date {
display: grid;
justify-items: end;
gap: 0.3rem;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
letter-spacing: -0.025em;
}
.record-date span {
color: rgb(35 30 30 / 32%);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.arrow-icon {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.back-link .arrow-icon {
transform: rotate(180deg);
}
.record-card > .arrow-icon {
color: rgb(35 30 30 / 30%);
}
.detail {
padding: 2rem;
}
.detail-heading {
display: flex;
align-items: center;
gap: 1rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgb(35 30 30 / 9%);
}
.detail-heading h2 {
margin: 0.45rem 0 0;
font-size: 1.5rem;
font-weight: 400;
letter-spacing: -0.05em;
}
dl {
display: grid;
grid-template-columns: 9rem minmax(0, 1fr);
gap: 1.4rem;
margin: 2rem 0 0;
}
dt {
color: rgb(35 30 30 / 48%);
font-size: 0.82rem;
}
dd {
min-width: 0;
margin: 0;
}
.sensitive {
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
background: #f6f6f1;
padding: 1rem;
line-height: 1.5;
}
.feedback-body {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.attachments {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: 0.75rem;
align-items: start;
}
.image-attachment {
overflow: hidden;
margin: 0;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1rem;
background: #f6f6f1;
}
.image-attachment a {
display: block;
}
.image-attachment img {
width: 100%;
max-height: 32rem;
display: block;
object-fit: contain;
background: rgb(35 30 30 / 4%);
}
.image-attachment figcaption,
.file-attachment {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
}
.image-attachment figcaption {
justify-content: space-between;
}
.image-attachment figcaption span,
.file-attachment strong {
min-width: 0;
overflow: hidden;
font-size: 0.82rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-main-link:hover strong {
text-decoration: underline;
text-underline-offset: 0.2rem;
}
.image-attachment small,
.file-attachment small {
color: rgb(35 30 30 / 48%);
font-size: 0.7rem;
}
.file-attachment {
align-self: start;
border: 1px solid rgb(35 30 30 / 10%);
border-radius: 1rem;
background: #f6f6f1;
}
.file-attachment:hover strong {
text-decoration: underline;
text-underline-offset: 0.15rem;
}
.file-attachment > svg:first-child {
width: 1.5rem;
height: 1.5rem;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-attachment > span {
min-width: 0;
display: grid;
flex: 1;
gap: 0.2rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.state {
min-height: 14rem;
display: grid;
place-content: center;
justify-items: center;
padding: 2rem;
color: rgb(35 30 30 / 56%);
text-align: center;
letter-spacing: -0.035em;
}
.state h2 {
margin: 0;
color: #231e1e;
font-size: 1.4rem;
font-weight: 400;
}
.state p {
margin: 0.75rem 0 1.25rem;
}
.state.error {
color: #9f2424;
}
@media (max-width: 720px) {
.app-header {
width: min(100% - 2rem, 1120px);
align-items: flex-start;
flex-direction: column;
gap: 1rem;
}
main {
width: min(100% - 2rem, 1120px);
padding-top: 3rem;
}
h1 {
font-size: clamp(3rem, 16vw, 4.5rem);
}
.record-card {
grid-template-columns: auto minmax(0, 1fr);
}
.feedback-filters {
grid-template-columns: 1fr;
}
.feedback-record {
grid-template-columns: minmax(0, 1fr) auto;
}
.feedback-main-link {
grid-column: 1 / -1;
}
.feedback-status {
grid-column: 1;
flex-wrap: wrap;
}
.feedback-record .record-date {
grid-column: 1;
}
.record-open-link {
grid-column: 2;
grid-row: 2 / span 2;
}
.record-date {
grid-column: 2;
justify-items: start;
}
.record-card > .arrow-icon {
display: none;
}
dl {
grid-template-columns: 1fr;
gap: 0.6rem;
}
dd + dt {
margin-top: 0.9rem;
}
}
+36
View File
@@ -0,0 +1,36 @@
export interface Report {
id: string;
communityId: string;
communityHost: string;
reporterPubkey: string;
targetKind: "event" | "pubkey" | "blob";
target: string;
channelId?: string;
reportType: string;
note?: string;
status: string;
createdAt: string;
}
export interface FeedbackSummary {
id: string;
communityId: string;
communityHost: string;
submitterPubkey: string;
category?: string;
bodySummary: string;
receivedAt: string;
}
export interface FeedbackDetail {
id: string;
communityId: string;
communityHost: string;
eventId: string;
submitterPubkey: string;
category?: string;
body: string;
tags: string[][];
eventCreatedAt: string;
receivedAt: string;
}
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface Resource<T> {
data?: T;
error?: Error;
loading: boolean;
stale: boolean;
refetch: () => void;
}
export function useResource<T>(
load: () => Promise<T>,
key: string,
): Resource<T> {
const [data, setData] = useState<T>();
const [error, setError] = useState<Error>();
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const loadRef = useRef(load);
const activeRequest = useRef("");
loadRef.current = load;
const refetch = useCallback(() => setRevision((value) => value + 1), []);
useEffect(() => {
const requestId = `${key}\0${revision}`;
activeRequest.current = requestId;
const isCurrent = () => activeRequest.current === requestId;
const loadCurrent = async () => {
setLoading(true);
setError(undefined);
try {
const value = await loadRef.current();
if (isCurrent()) setData(value);
} catch (reason) {
if (isCurrent()) {
setError(
reason instanceof Error ? reason : new Error("Request failed"),
);
}
} finally {
if (isCurrent()) setLoading(false);
}
};
void loadCurrent();
return () => {
activeRequest.current = "";
};
}, [key, revision]);
return {
data,
error,
loading,
stale: loading && data !== undefined,
refetch,
};
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+246
View File
@@ -0,0 +1,246 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.route("**/api/admin/v1/**", async (route) => {
await route.fulfill({ contentType: "application/json", body: "[]" });
});
});
for (const [path, heading] of [
["/reports", "Open reports"],
["/feedback", "Feedback"],
]) {
test(`${path} supports a deep link and empty state`, async ({ page }) => {
await page.goto(path);
await expect(page.getByRole("heading", { name: heading })).toBeVisible();
await expect(page.getByText("No records.")).toBeVisible();
});
}
test("forbidden reads have an explicit state", async ({ page }) => {
await page.route("**/api/admin/v1/reports?**", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
error: { code: "forbidden", message: "request is not authorized" },
}),
}),
);
await page.goto("/reports");
await expect(
page.getByRole("heading", { name: "Access denied" }),
).toBeVisible();
});
test("report rows render the relay response contract", async ({ page }) => {
await page.route("**/api/admin/v1/reports?**", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id: "0e6caad8-1e18-4cd7-84fa-7264103f0a08",
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
reporterPubkey: "21".repeat(32),
targetKind: "event",
target: "12".repeat(32),
reportType: "spam",
status: "open",
createdAt: "2026-07-17T17:30:00Z",
},
]),
}),
);
await page.goto("/reports");
await expect(page.getByText("design.buzz.xyz")).toBeVisible();
await expect(page.getByText("spam")).toBeVisible();
await expect(page.getByText("Unknown date")).toHaveCount(0);
});
test("feedback cards open the complete submission", async ({ page }) => {
const id = "feed0000-0000-4000-8000-000000000001";
const fullBody = `${"Long feedback ".repeat(30)}end of feedback`;
await page.route(`**/api/admin/v1/feedback/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
eventId: "31".repeat(32),
submitterPubkey: "21".repeat(32),
category: "needs-work",
body: fullBody,
tags: [],
eventCreatedAt: "2026-07-17T17:25:00Z",
receivedAt: "2026-07-17T17:30:00Z",
}),
}),
);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "needs-work",
bodySummary: `${fullBody.slice(0, 240)}`,
receivedAt: "2026-07-17T17:30:00Z",
},
]),
}),
);
await page.goto("/feedback");
const card = page.locator(".feedback-record");
await expect(card.locator(".record-provenance")).toContainText(
"design.buzz.xyz",
);
await card.locator(".feedback-main-link").click();
await expect(page).toHaveURL(`/feedback/${id}`);
await expect(
page.getByRole("heading", { name: "Feedback detail" }),
).toBeVisible();
await expect(
page.getByText("end of feedback", { exact: false }),
).toBeVisible();
});
test("feedback can be searched and filtered by community and time", async ({
page,
}) => {
const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id: "recent",
communityId: "one",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "bug",
bodySummary: "Composer freezes after sleep",
receivedAt: recent,
},
{
id: "old",
communityId: "two",
communityHost: "engineering.buzz.xyz",
submitterPubkey: "22".repeat(32),
category: "praise",
bodySummary: "Calls are much more reliable",
receivedAt: old,
},
]),
}),
);
await page.goto("/feedback");
await expect(page.getByText("2 of 2 submissions")).toBeVisible();
await page.getByRole("searchbox", { name: "Search feedback" }).fill("calls");
await expect(page.getByText("Calls are much more reliable")).toBeVisible();
await expect(page.getByText("Composer freezes after sleep")).toHaveCount(0);
await page.getByRole("searchbox", { name: "Search feedback" }).fill("");
await page.getByLabel("Community").selectOption("design.buzz.xyz");
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
await page.getByLabel("Community").selectOption("all");
await page.getByLabel("Received").selectOption("day");
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
await expect(page.getByText("Calls are much more reliable")).toHaveCount(0);
});
test("feedback status is stored locally by feedback id", async ({ page }) => {
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
{
id: "feedback-one",
communityId: "one",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "bug",
bodySummary: "Composer freezes after sleep",
receivedAt: new Date().toISOString(),
},
]),
}),
);
await page.goto("/feedback");
await page.getByRole("checkbox", { name: "Acted on" }).check();
await page.reload();
await expect(page.getByRole("checkbox", { name: "Acted on" })).toBeChecked();
await page.getByLabel("Status").selectOption("acted-on");
await expect(page.getByText("Composer freezes after sleep")).toBeVisible();
await page.getByLabel("Status").selectOption("pending");
await expect(page.getByText("No matching feedback.")).toBeVisible();
});
test("feedback attachments render from imeta without raw markdown", async ({
page,
}) => {
const id = "feedback-with-attachments";
const imageUrl = `https://design.buzz.xyz/media/${"a".repeat(64)}.png`;
const fileUrl = `https://design.buzz.xyz/media/${"b".repeat(64)}.txt`;
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.\n![image](${imageUrl})\n[diagnostics.txt](${fileUrl})`,
tags: [
[
"imeta",
`url ${imageUrl}`,
"m image/png",
`x ${"a".repeat(64)}`,
"size 48213",
"dim 1280x720",
"filename screenshot.png",
],
[
"imeta",
`url ${fileUrl}`,
"m text/plain",
`x ${"b".repeat(64)}`,
"size 391",
"filename diagnostics.txt",
],
],
eventCreatedAt: "2026-07-17T17:25:00Z",
receivedAt: "2026-07-17T17:30:00Z",
}),
}),
);
await page.goto(`/feedback/${id}`);
await expect(
page.getByText("Composer froze.", { exact: true }),
).toBeVisible();
await expect(page.getByText("![image]", { exact: false })).toHaveCount(0);
await expect(
page.getByRole("img", { name: "screenshot.png" }),
).toHaveAttribute("src", imageUrl);
await expect(
page.getByRole("link", { name: /diagnostics.txt/ }),
).toHaveAttribute("href", fileUrl);
const fileHeight = await page
.locator(".file-attachment")
.evaluate((element) => element.getBoundingClientRect().height);
expect(fileHeight).toBeLessThan(100);
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts", "playwright.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: { port: 4174 },
build: { sourcemap: false },
});
+230
View File
@@ -0,0 +1,230 @@
//! Explicit deployment-global reads for the private deployment-admin plane.
//!
//! This module is the only moderation repository allowed to omit a
//! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in
//! [`crate::moderation`] tenant-fenced.
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::{PgPool, Row as _};
use uuid::Uuid;
use crate::error::Result;
/// Maximum rows accepted by one admin query.
pub const MAX_PAGE_SIZE: i64 = 200;
fn bounded_limit(limit: i64) -> i64 {
limit.clamp(1, MAX_PAGE_SIZE)
}
/// Deployment-global moderation report.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminReport {
/// Report row identifier.
pub id: Uuid,
/// Community identifier.
pub community_id: Uuid,
/// Community host.
pub community_host: String,
/// Signed report event identifier.
pub report_event_id: String,
/// Reporter public key.
pub reporter_pubkey: String,
/// Target class.
pub target_kind: String,
/// Hex target identifier.
pub target: String,
/// Optional channel.
pub channel_id: Option<Uuid>,
/// NIP-56 report category.
pub report_type: String,
/// Private reporter note.
pub note: Option<String>,
/// Lifecycle status.
pub status: String,
/// Resolving principal pubkey.
pub resolved_by: Option<String>,
/// Resolution time.
pub resolved_at: Option<DateTime<Utc>>,
/// Linked action.
pub action_id: Option<Uuid>,
/// Creation time.
pub created_at: DateTime<Utc>,
}
/// Deployment-global product feedback with source-community provenance.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminFeedback {
/// Feedback row identifier.
pub id: Uuid,
/// Source community identifier.
pub community_id: Uuid,
/// Source community host.
pub community_host: String,
/// Signed feedback event identifier.
pub event_id: String,
/// Submitter public key.
pub submitter_pubkey: String,
/// Optional feedback category.
pub category: Option<String>,
/// Full feedback body.
pub body: String,
/// Full source tags, including attachment metadata.
pub tags: serde_json::Value,
/// Timestamp signed into the feedback event.
pub event_created_at: DateTime<Utc>,
/// Time accepted by this deployment.
pub received_at: DateTime<Utc>,
}
/// List reports across all communities by stable descending keyset.
#[allow(clippy::too_many_arguments)]
pub async fn list_reports(
pool: &PgPool,
community_id: Option<Uuid>,
status: Option<&str>,
report_type: Option<&str>,
target_kind: Option<&str>,
after: Option<DateTime<Utc>>,
before: Option<DateTime<Utc>>,
cursor: Option<(DateTime<Utc>, Uuid)>,
limit: i64,
) -> Result<Vec<AdminReport>> {
let (cursor_time, cursor_id) = cursor.unzip();
let rows = sqlx::query(
r#"
SELECT r.id, r.community_id, c.host AS community_host,
r.report_event_id, r.reporter_pubkey, r.target_kind,
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
r.resolved_at, r.action_id, r.created_at
FROM moderation_reports r
JOIN communities c ON c.id = r.community_id
WHERE ($1::uuid IS NULL OR r.community_id = $1)
AND ($2::text IS NULL OR r.status = $2)
AND ($3::text IS NULL OR r.report_type = $3)
AND ($4::text IS NULL OR r.target_kind = $4)
AND ($5::timestamptz IS NULL OR r.created_at >= $5)
AND ($6::timestamptz IS NULL OR r.created_at < $6)
AND ($7::timestamptz IS NULL OR (r.created_at, r.id) < ($7, $8))
ORDER BY r.created_at DESC, r.id DESC
LIMIT $9
"#,
)
.bind(community_id)
.bind(status)
.bind(report_type)
.bind(target_kind)
.bind(after)
.bind(before)
.bind(cursor_time)
.bind(cursor_id)
.bind(bounded_limit(limit))
.fetch_all(pool)
.await?;
rows.into_iter().map(row_to_report).collect()
}
/// Fetch one report globally by its row id.
pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminReport>> {
let row = sqlx::query(
r#"
SELECT r.id, r.community_id, c.host AS community_host,
r.report_event_id, r.reporter_pubkey, r.target_kind,
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
r.resolved_at, r.action_id, r.created_at
FROM moderation_reports r
JOIN communities c ON c.id = r.community_id
WHERE r.id = $1
"#,
)
.bind(report_id)
.fetch_optional(pool)
.await?;
row.map(row_to_report).transpose()
}
fn row_to_report(row: sqlx::postgres::PgRow) -> Result<AdminReport> {
let target_kind: String = row.try_get("target_kind")?;
let target = match target_kind.as_str() {
"event" => row.try_get::<Vec<u8>, _>("target_event_id")?,
"pubkey" => row.try_get::<Vec<u8>, _>("target_pubkey")?,
"blob" => row.try_get::<Vec<u8>, _>("target_blob_sha256")?,
_ => Vec::new(),
};
Ok(AdminReport {
id: row.try_get("id")?,
community_id: row.try_get("community_id")?,
community_host: row.try_get("community_host")?,
report_event_id: hex::encode(row.try_get::<Vec<u8>, _>("report_event_id")?),
reporter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("reporter_pubkey")?),
target_kind,
target: hex::encode(target),
channel_id: row.try_get("channel_id")?,
report_type: row.try_get("report_type")?,
note: row.try_get("note")?,
status: row.try_get("status")?,
resolved_by: row
.try_get::<Option<Vec<u8>>, _>("resolved_by")?
.map(hex::encode),
resolved_at: row.try_get("resolved_at")?,
action_id: row.try_get("action_id")?,
created_at: row.try_get("created_at")?,
})
}
/// List product feedback across all communities, newest first.
pub async fn list_feedback(pool: &PgPool, limit: i64) -> Result<Vec<AdminFeedback>> {
let rows = sqlx::query(
r#"
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
f.submitter_pubkey, f.category, f.body, f.tags,
f.event_created_at, f.received_at
FROM product_feedback f
JOIN communities c ON c.id = f.community_id
ORDER BY f.received_at DESC, f.id DESC
LIMIT $1
"#,
)
.bind(bounded_limit(limit))
.fetch_all(pool)
.await?;
rows.into_iter().map(row_to_feedback).collect()
}
/// Fetch one feedback submission globally by its row id.
pub async fn get_feedback(pool: &PgPool, id: Uuid) -> Result<Option<AdminFeedback>> {
let row = sqlx::query(
r#"
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
f.submitter_pubkey, f.category, f.body, f.tags,
f.event_created_at, f.received_at
FROM product_feedback f
JOIN communities c ON c.id = f.community_id
WHERE f.id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
row.map(row_to_feedback).transpose()
}
fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result<AdminFeedback> {
Ok(AdminFeedback {
id: row.try_get("id")?,
community_id: row.try_get("community_id")?,
community_host: row.try_get("community_host")?,
event_id: hex::encode(row.try_get::<Vec<u8>, _>("event_id")?),
submitter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("submitter_pubkey")?),
category: row.try_get("category")?,
body: row.try_get("body")?,
tags: row.try_get("tags")?,
event_created_at: row.try_get("event_created_at")?,
received_at: row.try_get("received_at")?,
})
}
+53
View File
@@ -9,6 +9,8 @@
//! - No FK references to partitioned tables.
//! - Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time).
/// Explicit deployment-global admin report reads.
pub mod admin_moderation;
/// API token storage and lookup.
pub mod api_token;
/// Relay-scoped archived identity persistence (NIP-IA).
@@ -397,6 +399,57 @@ impl Db {
}
}
/// List reports for the deployment-global read-only admin plane.
#[allow(clippy::too_many_arguments)]
pub async fn admin_list_reports(
&self,
community_id: Option<Uuid>,
status: Option<&str>,
report_type: Option<&str>,
target_kind: Option<&str>,
after: Option<DateTime<Utc>>,
before: Option<DateTime<Utc>>,
cursor: Option<(DateTime<Utc>, Uuid)>,
limit: i64,
) -> Result<Vec<admin_moderation::AdminReport>> {
admin_moderation::list_reports(
&self.pool,
community_id,
status,
report_type,
target_kind,
after,
before,
cursor,
limit,
)
.await
}
/// Fetch one report for the deployment-global read-only admin plane.
pub async fn admin_get_report(
&self,
id: Uuid,
) -> Result<Option<admin_moderation::AdminReport>> {
admin_moderation::get_report(&self.pool, id).await
}
/// List feedback for the deployment-global read-only admin plane.
pub async fn admin_list_feedback(
&self,
limit: i64,
) -> Result<Vec<admin_moderation::AdminFeedback>> {
admin_moderation::list_feedback(&self.pool, limit).await
}
/// Fetch one feedback submission for the deployment-global admin plane.
pub async fn admin_get_feedback(
&self,
id: Uuid,
) -> Result<Option<admin_moderation::AdminFeedback>> {
admin_moderation::get_feedback(&self.pool, id).await
}
/// Return total number of communities on this relay.
pub async fn usage_community_count(&self) -> Result<i64> {
usage::community_count(&self.pool).await
+62
View File
@@ -0,0 +1,62 @@
use axum::http::{header, HeaderMap};
use super::error::ApiError;
use crate::state::AppState;
pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool {
let Some(config) = state.config.admin.as_ref() else {
return false;
};
headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.is_some_and(|host| host == config.host)
}
pub fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
let config = state
.config
.admin
.as_ref()
.ok_or_else(ApiError::not_found)?;
if !is_admin_host(state, headers) {
return Err(ApiError::forbidden());
}
if headers.get(header::ORIGIN).is_some_and(|origin| {
origin
.to_str()
.map_or(true, |origin| !origin_matches_host(origin, &config.host))
}) {
return Err(ApiError::forbidden());
}
Ok(())
}
fn origin_matches_host(origin: &str, host: &str) -> bool {
origin
.strip_prefix("https://")
.or_else(|| origin.strip_prefix("http://"))
== Some(host)
}
#[cfg(test)]
mod tests {
use super::origin_matches_host;
#[test]
fn browser_origin_must_match_admin_host() {
assert!(origin_matches_host(
"https://admin.example.com",
"admin.example.com"
));
assert!(origin_matches_host(
"http://admin.localhost:3000",
"admin.localhost:3000"
));
assert!(!origin_matches_host(
"https://attacker.example",
"admin.example.com"
));
assert!(!origin_matches_host("null", "admin.example.com"));
}
}
+82
View File
@@ -0,0 +1,82 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub code: &'static str,
pub message: &'static str,
}
#[derive(Serialize)]
struct ErrorEnvelope {
error: ErrorBody,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ErrorBody {
code: &'static str,
message: &'static str,
request_id: uuid::Uuid,
}
impl ApiError {
pub fn bad_request(code: &'static str, message: &'static str) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code,
message,
}
}
pub fn forbidden() -> Self {
Self {
status: StatusCode::FORBIDDEN,
code: "forbidden",
message: "request is not authorized",
}
}
pub fn not_found() -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "not_found",
message: "record was not found",
}
}
pub fn internal() -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "internal_error",
message: "request failed",
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorEnvelope {
error: ErrorBody {
code: self.code,
message: self.message,
request_id: uuid::Uuid::new_v4(),
},
}),
)
.into_response()
}
}
impl From<buzz_db::DbError> for ApiError {
fn from(_: buzz_db::DbError) -> Self {
Self::internal()
}
}
+247
View File
@@ -0,0 +1,247 @@
//! Private, read-only deployment moderation API.
mod auth;
mod error;
use std::sync::Arc;
use auth::authorize;
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, HeaderValue},
middleware::{self, Next},
response::Response,
routing::get,
Json, Router,
};
use chrono::{DateTime, Utc};
use error::ApiError;
use serde::{Deserialize, Serialize};
use tower_http::limit::RequestBodyLimitLayer;
use uuid::Uuid;
pub(crate) fn is_admin_host(state: &crate::state::AppState, headers: &HeaderMap) -> bool {
auth::is_admin_host(state, headers)
}
/// Build the read-only deployment-admin routes.
pub fn router(state: Arc<crate::state::AppState>) -> Router {
Router::new()
.route("/reports", get(reports))
.route("/reports/{id}", get(report_detail))
.route("/feedback", get(feedback))
.route("/feedback/{id}", get(feedback_detail))
.layer(middleware::from_fn(security_headers))
.layer(RequestBodyLimitLayer::new(1024))
.with_state(state)
}
async fn security_headers(request: axum::extract::Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
);
response
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ReportQuery {
community_id: Option<Uuid>,
status: Option<String>,
report_type: Option<String>,
target_kind: Option<String>,
before: Option<DateTime<Utc>>,
after: Option<DateTime<Utc>>,
limit: Option<i64>,
}
fn limit(value: Option<i64>) -> Result<i64, ApiError> {
match value.unwrap_or(50) {
value @ 1..=200 => Ok(value),
_ => Err(ApiError::bad_request(
"invalid_limit",
"limit must be between 1 and 200",
)),
}
}
fn validate(value: Option<&str>, allowed: &[&str], code: &'static str) -> Result<(), ApiError> {
if value.is_some_and(|value| !allowed.contains(&value)) {
Err(ApiError::bad_request(code, "filter is invalid"))
} else {
Ok(())
}
}
async fn reports(
State(state): State<Arc<crate::state::AppState>>,
headers: HeaderMap,
Query(query): Query<ReportQuery>,
) -> Result<Json<Vec<buzz_db::admin_moderation::AdminReport>>, ApiError> {
authorize(&state, &headers)?;
validate(
query.status.as_deref(),
&["open", "resolved", "dismissed", "escalated"],
"invalid_status",
)?;
validate(
query.target_kind.as_deref(),
&["event", "pubkey", "blob"],
"invalid_target_kind",
)?;
let items = state
.db
.admin_list_reports(
query.community_id,
query.status.as_deref(),
query.report_type.as_deref(),
query.target_kind.as_deref(),
query.after,
query.before,
None,
limit(query.limit)?,
)
.await?;
Ok(Json(items))
}
async fn report_detail(
State(state): State<Arc<crate::state::AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<buzz_db::admin_moderation::AdminReport>, ApiError> {
authorize(&state, &headers)?;
state
.db
.admin_get_report(id)
.await?
.map(Json)
.ok_or_else(ApiError::not_found)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FeedbackSummary {
id: Uuid,
community_id: Uuid,
community_host: String,
submitter_pubkey: String,
category: Option<String>,
body_summary: String,
received_at: DateTime<Utc>,
}
async fn feedback(
State(state): State<Arc<crate::state::AppState>>,
headers: HeaderMap,
) -> Result<Json<Vec<FeedbackSummary>>, ApiError> {
authorize(&state, &headers)?;
let items = state
.db
.admin_list_feedback(100)
.await?
.into_iter()
.map(|item| {
let body_summary = summarize_body(&item.body, &item.tags);
FeedbackSummary {
id: item.id,
community_id: item.community_id,
community_host: item.community_host,
submitter_pubkey: item.submitter_pubkey,
category: item.category,
body_summary,
received_at: item.received_at,
}
})
.collect();
Ok(Json(items))
}
async fn feedback_detail(
State(state): State<Arc<crate::state::AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<buzz_db::admin_moderation::AdminFeedback>, ApiError> {
authorize(&state, &headers)?;
state
.db
.admin_get_feedback(id)
.await?
.map(Json)
.ok_or_else(ApiError::not_found)
}
fn summarize_body(body: &str, tags: &serde_json::Value) -> String {
const MAX_CHARS: usize = 240;
let attachment_urls = tags
.as_array()
.into_iter()
.flatten()
.filter_map(|tag| tag.as_array())
.filter(|tag| tag.first().and_then(|value| value.as_str()) == Some("imeta"))
.flat_map(|tag| tag.iter().skip(1))
.filter_map(|value| value.as_str()?.strip_prefix("url "))
.collect::<std::collections::HashSet<_>>();
let body = body
.lines()
.filter(|line| {
let line = line.trim();
let url = line
.strip_suffix(')')
.and_then(|line| line.rsplit_once("]("))
.and_then(|(label, url)| {
(label.starts_with('[') || label.starts_with("![")).then_some(url)
});
url.is_none_or(|url| !attachment_urls.contains(url))
})
.collect::<Vec<_>>()
.join("\n");
let mut chars = body.trim().chars();
let mut summary = chars.by_ref().take(MAX_CHARS).collect::<String>();
if chars.next().is_some() {
summary.push('…');
}
summary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_filters_reject_unknown_values() {
assert!(validate(Some("open"), &["open"], "invalid_status").is_ok());
assert!(validate(Some("unknown"), &["open"], "invalid_status").is_err());
}
#[test]
fn feedback_summary_is_unicode_safe_and_marks_truncation() {
let body = "🐝".repeat(241);
let summary = summarize_body(&body, &serde_json::Value::Null);
assert_eq!(summary.chars().count(), 241);
assert!(summary.ends_with('…'));
}
#[test]
fn feedback_summary_omits_imeta_attachment_lines() {
let url = "http://localhost:3000/media/abc.png";
let tags = serde_json::json!([["imeta", format!("url {url}"), "m image/png"]]);
assert_eq!(
summarize_body(&format!("Useful context.\n![image]({url})"), &tags),
"Useful context."
);
}
}
+1
View File
@@ -1,5 +1,6 @@
//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge.
pub mod admin;
pub mod bridge;
pub mod events;
pub mod git;
+42
View File
@@ -24,6 +24,15 @@ pub enum ConfigError {
InvalidValue(String),
}
/// Deny-by-default read-only deployment-admin configuration.
#[derive(Debug, Clone)]
pub struct AdminConfig {
/// Exact admin HTTP authority.
pub host: String,
/// Optional admin SPA bundle directory.
pub web_dir: Option<std::path::PathBuf>,
}
/// Relay-hosted policy content presented on join surfaces.
#[derive(Debug, Clone)]
pub struct JoinPolicyConfig {
@@ -219,6 +228,9 @@ pub struct Config {
/// documents or age attestation are configured.
pub join_policy: Option<JoinPolicyConfig>,
/// Deployment-admin API and SPA configuration. Absent means the surface is disabled.
pub admin: Option<AdminConfig>,
/// Optional path to the web UI `dist/` directory.
/// When set, the relay serves the invite landing page and its static assets.
/// When unset, no static file serving happens (relay behaves as before).
@@ -737,6 +749,35 @@ impl Config {
})
};
// Read-only deployment-admin surface. The route is absent when the host is unset.
let admin = match std::env::var("BUZZ_ADMIN_HOST")
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
{
None => None,
Some(host) => {
if host.contains(['/', '\\', '@']) {
return Err(ConfigError::InvalidValue(
"BUZZ_ADMIN_HOST must be an exact authority".to_string(),
));
}
let web_dir = std::env::var("BUZZ_ADMIN_WEB_DIR")
.ok()
.map(|value| std::path::PathBuf::from(value.trim()))
.filter(|value| !value.as_os_str().is_empty());
if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_ADMIN_WEB_DIR={} does not contain index.html",
dir.display()
)));
}
}
Some(AdminConfig { host, web_dir })
}
};
// Web UI static file serving
let web_dir = std::env::var("BUZZ_WEB_DIR")
.ok()
@@ -810,6 +851,7 @@ impl Config {
push_gateway_delivery_url,
push_gateway_timeout,
join_policy,
admin,
web_dir,
serve_git_web_gui,
})
@@ -20,6 +20,18 @@ pub async fn handle(
) -> Result<(), String> {
let category = parse_category(event)?;
validate_body(&event.content)?;
let imeta_tags = event
.tags
.iter()
.filter(|tag| tag.kind().to_string() == "imeta")
.map(|tag| tag.as_slice().iter().map(ToString::to_string).collect())
.collect::<Vec<Vec<String>>>();
if !imeta_tags.is_empty() {
let media_base =
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
crate::api::validate_imeta_tags(&imeta_tags, &media_base)?;
crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage).await?;
}
let tags = serialize_tags(event)?;
let event_created_at =
+71 -15
View File
@@ -49,6 +49,15 @@ pub fn build_router(state: Arc<AppState>) -> Router {
let git_policy_router = api::git::git_policy_router(state.clone());
let admin_enabled = state.config.admin.is_some();
let admin_web_dir = state
.config
.admin
.as_ref()
.and_then(|config| config.web_dir.clone());
let admin_router = admin_enabled
.then(|| Router::new().nest("/api/admin/v1", api::admin::router(state.clone())));
let api_router = Router::new()
// WebSocket + NIP-11
.route("/", get(nip11_or_ws_handler))
@@ -127,28 +136,48 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.merge(media_router)
.merge(git_router)
.merge(git_policy_router);
if let Some(admin_router) = admin_router {
merged = merged.merge(admin_router);
}
// When BUZZ_WEB_DIR is set, serve either the full SPA or its invite-only
// surface. Invite-only mode deliberately exposes only /invite/{code} and
// hashed build assets; root and repository browser routes remain absent.
if let Some(ref web_dir) = state.config.web_dir {
let index_path = web_dir.join("index.html");
let static_files = ServeDir::new(web_dir);
// Serve both bundles from one fallback. The admin host is checked first so
// it can never fall through to the public web bundle.
let web_dir = state.config.web_dir.clone();
if admin_web_dir.is_some() || web_dir.is_some() {
let admin_index = admin_web_dir.as_ref().map(|dir| dir.join("index.html"));
let admin_files = admin_web_dir.map(ServeDir::new);
let web_index = web_dir.as_ref().map(|dir| dir.join("index.html"));
let web_files = web_dir.map(ServeDir::new);
let serve_git_web_gui = state.config.serve_git_web_gui;
let fallback_state = state.clone();
let spa_fallback = tower::service_fn(move |req: axum::extract::Request| {
let index = index_path.clone();
let static_files = static_files.clone();
let admin_index = admin_index.clone();
let admin_files = admin_files.clone();
let web_index = web_index.clone();
let web_files = web_files.clone();
let state = fallback_state.clone();
async move {
let path = req.uri().path();
if path.starts_with("/assets/") {
return static_files
.oneshot(req)
.await
.map(IntoResponse::into_response);
let admin_host = api::admin::is_admin_host(&state, req.headers());
if admin_host {
if let (Some(index), Some(files)) = (admin_index, admin_files) {
if path.starts_with("/assets/") {
return files.oneshot(req).await.map(IntoResponse::into_response);
}
if is_admin_spa_path(path) {
return Ok(read_spa_index(&index).await);
}
}
return Ok(StatusCode::NOT_FOUND.into_response());
}
if should_serve_spa(path, serve_git_web_gui) {
return Ok(read_spa_index(&index).await);
if let (Some(index), Some(files)) = (web_index, web_files) {
if path.starts_with("/assets/") {
return files.oneshot(req).await.map(IntoResponse::into_response);
}
if should_serve_spa(path, serve_git_web_gui) {
return Ok(read_spa_index(&index).await);
}
}
Ok(StatusCode::NOT_FOUND.into_response())
}
@@ -162,6 +191,14 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.layer(build_cors_layer(&state.config.cors_origins))
}
fn is_admin_spa_path(path: &str) -> bool {
path == "/"
|| path == "/reports"
|| path.starts_with("/reports/")
|| path == "/feedback"
|| path.starts_with("/feedback/")
}
fn is_invite_landing_path(path: &str) -> bool {
path.strip_prefix("/invite/")
.is_some_and(|code| !code.is_empty() && !code.contains('/'))
@@ -216,6 +253,25 @@ async fn nip11_or_ws_handler(
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// `/` is an explicit relay route, so it never reaches the SPA fallback.
// Short-circuit the exact admin authority here and never let it serve the
// public web bundle, NIP-11 document, or WebSocket endpoint.
if api::admin::is_admin_host(&state, &headers) {
if !accept.contains("text/html") {
return StatusCode::NOT_FOUND.into_response();
}
let Some(index) = state
.config
.admin
.as_ref()
.and_then(|config| config.web_dir.as_ref())
.map(|dir| dir.join("index.html"))
else {
return StatusCode::NOT_FOUND.into_response();
};
return read_spa_index(&index).await;
}
if accept.contains("application/nostr+json") {
return Json(nip11_document(&state, raw_host).await).into_response();
}
+38
View File
@@ -0,0 +1,38 @@
# Read-only deployment moderation dashboard
Buzz can expose a private, deployment-wide read-only dashboard from the existing
relay process. It shows open moderation reports and recent product feedback.
Configure `BUZZ_ADMIN_HOST` to activate the dashboard. A private ingress limits
access to the operator VPN or approved source IPs.
Required configuration:
```text
BUZZ_ADMIN_HOST=admin.example.com
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
```
The relay requires the configured admin host and matching browser origin.
Requests and responses are bounded and uncached. The deployment routes admin
traffic through the private ingress.
When the UI runs in a separate pod, proxy `/api/admin/v1/*` to the relay while
preserving the admin `Host` header. A `NetworkPolicy` grants the admin pod access
to that relay path.
Read routes:
- `GET /api/admin/v1/reports`
- `GET /api/admin/v1/reports/:id`
- `GET /api/admin/v1/feedback`
- `GET /api/admin/v1/feedback/:id`
Report reads accept optional `communityId`, `status`, `reportType`, `targetKind`,
`after`, `before`, and `limit` parameters. Limits are capped at 200. Feedback is
a bounded newest-first summary from the existing product-feedback repository.
For local review, run `just admin-seed` before `just admin`. The seed command
also uploads real image and diagnostic fixtures to local MinIO. Feedback search
and filters run over the bounded browser result set; the **Acted on** checkbox is
stored in that browser's local storage.
+810 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
packages:
- "desktop"
- "web"
- "admin-web"
allowBuilds:
esbuild: true
overrides:
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# Seed deterministic moderation reports and product feedback for local dashboard review.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
if [[ -f ".env" ]]; then
set -o allexport
# shellcheck disable=SC1091
source .env
set +o allexport
fi
export PGHOST="${PGHOST:-localhost}"
export PGPORT="${PGPORT:-5432}"
export PGUSER="${PGUSER:-buzz}"
export PGPASSWORD="${PGPASSWORD:-buzz_dev}"
export PGDATABASE="${PGDATABASE:-buzz}"
if command -v psql >/dev/null 2>&1; then
run_psql() {
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" \
-U "${PGUSER}" -d "${PGDATABASE}" "$@"
}
elif docker exec buzz-postgres psql --version >/dev/null 2>&1; then
run_psql() {
docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \
psql -U "${PGUSER}" -d "${PGDATABASE}" "$@"
}
else
echo "error: neither psql nor buzz-postgres docker psql is available" >&2
exit 1
fi
community_id="$(run_psql -At -v ON_ERROR_STOP=1 -c "
SELECT id
FROM communities
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
ORDER BY CASE lower(host)
WHEN 'localhost:3000' THEN 1
WHEN 'localhost' THEN 2
WHEN '127.0.0.1:3000' THEN 3
ELSE 4
END
LIMIT 1
")"
if [[ -z "${community_id}" ]]; then
echo "error: local community is missing; run just setup first" >&2
exit 1
fi
fixture_hash() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
fixture_size() {
wc -c < "$1" | awk '{print $1}'
}
upload_fixture() {
local path="$1" hash="$2" extension="$3" mime="$4" dimensions="$5"
local size sidecar
size="$(fixture_size "${path}")"
sidecar="$(printf '{"dim":"%s","blurhash":"","thumb_url":"","ext":"%s","mime_type":"%s","size":%s,"uploaded_at":0}' \
"${dimensions}" "${extension}" "${mime}" "${size}")"
docker exec -i buzz-minio mc pipe --quiet --attr "Content-Type=${mime}" \
"local/${BUZZ_S3_BUCKET:-buzz-media}/${hash}.${extension}" < "${path}"
printf '%s' "${sidecar}" | docker exec -i buzz-minio mc pipe --quiet \
--attr "Content-Type=application/json" \
"local/${BUZZ_S3_BUCKET:-buzz-media}/_meta/${community_id}/${hash}.json"
}
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-admin-feedback.XXXXXX")"
search_image="${REPO_ROOT}/docs/assets/screenshots/media-comments.png"
workspace_image="${REPO_ROOT}/docs/assets/screenshots/channel-thread.png"
quality_image="${REPO_ROOT}/docs/assets/screenshots/channel-agents.png"
composer_diagnostics="${fixture_dir}/composer-diagnostics.txt"
workspace_diagnostics="${fixture_dir}/workspace-diagnostics.txt"
trap 'rm -f "${composer_diagnostics}" "${workspace_diagnostics}"; rmdir "${fixture_dir}"' EXIT
printf '%s\n' "buzz feedback diagnostics" "area: composer" \
"event: resumed_from_sleep" "result: composer_unresponsive" > "${composer_diagnostics}"
printf '%s\n' "buzz feedback diagnostics" "area: workspace-switching" \
"from: design" "to: engineering" \
"result: previous_sidebar_visible_for_one_frame" > "${workspace_diagnostics}"
search_image_hash="$(fixture_hash "${search_image}")"
workspace_image_hash="$(fixture_hash "${workspace_image}")"
quality_image_hash="$(fixture_hash "${quality_image}")"
composer_diagnostics_hash="$(fixture_hash "${composer_diagnostics}")"
workspace_diagnostics_hash="$(fixture_hash "${workspace_diagnostics}")"
if ! docker exec buzz-minio mc alias set local http://localhost:9000 \
"${BUZZ_S3_ACCESS_KEY:-buzz_dev}" "${BUZZ_S3_SECRET_KEY:-buzz_dev_secret}" >/dev/null; then
echo "error: local MinIO is unavailable; run just setup first" >&2
exit 1
fi
upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
read -r -d '' sql <<'SQL' || true
DO $$
DECLARE
local_community_id UUID;
BEGIN
SELECT id INTO local_community_id
FROM communities
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
ORDER BY CASE lower(host)
WHEN 'localhost:3000' THEN 1
WHEN 'localhost' THEN 2
WHEN '127.0.0.1:3000' THEN 3
ELSE 4
END
LIMIT 1;
IF local_community_id IS NULL THEN
RAISE EXCEPTION 'local community is missing; run just setup first';
END IF;
INSERT INTO moderation_reports (
community_id, id, report_event_id, reporter_pubkey, target_kind,
target_event_id, target_pubkey, target_blob_sha256, report_type, note,
status, resolved_by, resolved_at, created_at
) VALUES
(local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'),
(local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days')
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
reporter_pubkey = EXCLUDED.reporter_pubkey,
target_kind = EXCLUDED.target_kind,
target_event_id = EXCLUDED.target_event_id,
target_pubkey = EXCLUDED.target_pubkey,
target_blob_sha256 = EXCLUDED.target_blob_sha256,
report_type = EXCLUDED.report_type,
note = EXCLUDED.note,
status = EXCLUDED.status,
resolved_by = EXCLUDED.resolved_by,
resolved_at = EXCLUDED.resolved_at,
created_at = EXCLUDED.created_at;
INSERT INTO product_feedback (
id, community_id, event_id, submitter_pubkey, category, body, tags,
event_created_at, received_at
) VALUES
('feed0000-0000-4000-8000-000000000001', local_community_id, decode(repeat('41', 32), 'hex'), decode(repeat('51', 32), 'hex'), 'bug', 'Unread counts return after reopening the desktop app.', '[["category", "bug"]]', now() - interval '20 minutes', now() - interval '19 minutes'),
('feed0000-0000-4000-8000-000000000002', local_community_id, decode(repeat('42', 32), 'hex'), decode(repeat('52', 32), 'hex'), 'needs-work', E'Search needs clearer empty-state guidance.\n![image](http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png)', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png", "m image/png", "x __SEARCH_IMAGE_HASH__", "size __SEARCH_IMAGE_SIZE__", "dim 2000x1172", "filename search-empty-state.png"]]', now() - interval '5 hours', now() - interval '5 hours'),
('feed0000-0000-4000-8000-000000000003', local_community_id, decode(repeat('43', 32), 'hex'), decode(repeat('53', 32), 'hex'), 'praise', 'The new channel switcher feels immediate.', '[["category", "praise"]]', now() - interval '1 day', now() - interval '1 day'),
('feed0000-0000-4000-8000-000000000004', local_community_id, decode(repeat('44', 32), 'hex'), decode(repeat('54', 32), 'hex'), 'bug', E'The composer froze after waking my laptop. Diagnostics attached.\n[feedback-diagnostics.txt](http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt)', '[["category", "bug"], ["imeta", "url http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __COMPOSER_DIAGNOSTICS_HASH__", "size __COMPOSER_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '2 days', now() - interval '2 days'),
('feed0000-0000-4000-8000-000000000005', local_community_id, decode(repeat('45', 32), 'hex'), decode(repeat('55', 32), 'hex'), NULL, 'General feedback without a selected category or any attachments.', '[]', now() - interval '3 days', now() - interval '3 days'),
('feed0000-0000-4000-8000-000000000006', local_community_id, decode(repeat('46', 32), 'hex'), decode(repeat('56', 32), 'hex'), 'needs-work', E'The sidebar briefly renders the previous workspace after switching. Screenshot and diagnostics attached.\n![image](http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png)\n[feedback-diagnostics.txt](http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt)', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png", "m image/png", "x __WORKSPACE_IMAGE_HASH__", "size __WORKSPACE_IMAGE_SIZE__", "dim 2000x1172", "filename workspace-flash.png"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __WORKSPACE_DIAGNOSTICS_HASH__", "size __WORKSPACE_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '5 days', now() - interval '5 days'),
('feed0000-0000-4000-8000-000000000007', local_community_id, decode(repeat('47', 32), 'hex'), decode(repeat('57', 32), 'hex'), 'praise', E'Calls have been much more reliable this week. Attaching the quality graph that made the improvement obvious.\n![image](http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png)', '[["category", "praise"], ["imeta", "url http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png", "m image/png", "x __QUALITY_IMAGE_HASH__", "size __QUALITY_IMAGE_SIZE__", "dim 2000x1172", "filename huddle-quality.png"]]', now() - interval '8 days', now() - interval '8 days')
ON CONFLICT (event_id) DO UPDATE SET
community_id = EXCLUDED.community_id,
submitter_pubkey = EXCLUDED.submitter_pubkey,
category = EXCLUDED.category,
body = EXCLUDED.body,
tags = EXCLUDED.tags,
event_created_at = EXCLUDED.event_created_at,
received_at = EXCLUDED.received_at;
END $$;
SQL
sql="${sql//__SEARCH_IMAGE_HASH__/${search_image_hash}}"
sql="${sql//__WORKSPACE_IMAGE_HASH__/${workspace_image_hash}}"
sql="${sql//__QUALITY_IMAGE_HASH__/${quality_image_hash}}"
sql="${sql//__COMPOSER_DIAGNOSTICS_HASH__/${composer_diagnostics_hash}}"
sql="${sql//__WORKSPACE_DIAGNOSTICS_HASH__/${workspace_diagnostics_hash}}"
sql="${sql//__SEARCH_IMAGE_SIZE__/$(fixture_size "${search_image}")}"
sql="${sql//__WORKSPACE_IMAGE_SIZE__/$(fixture_size "${workspace_image}")}"
sql="${sql//__QUALITY_IMAGE_SIZE__/$(fixture_size "${quality_image}")}"
sql="${sql//__COMPOSER_DIAGNOSTICS_SIZE__/$(fixture_size "${composer_diagnostics}")}"
sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnostics}")}"
run_psql -v ON_ERROR_STOP=1 -c "${sql}"
echo "Seeded 10 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."