refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777)

Adds authenticated, role-based moderation to the relay admin API. On
`main` the admin API is read-only and gated only by `Host`/`Origin`
matching; this branch adds NIP-98 authentication, a two-tier
Operator/Moderator principal model, mutation and staffing endpoints, and
NIP-11 auto-discovery so clients never type the admin URL by hand.

## Authentication (`BUZZ_ADMIN_AUTH`)

`BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset
defaults to `nip98` (fail-secure). Configuration fails closed: any other
value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored
with a startup warning — token (bearer) authentication is not supported.
`Host`/`Origin` matching is retained in every mode as defense-in-depth.

- **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events,
resolved to an Operator or Moderator principal with per-person
attribution and individual revocability. Read-write per resolved
principal.
- **`disabled`** — no credential; relies entirely on network-layer
controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot.
Always read-only: `authorize()` resolves no principal, so mutation and
staffing routes always `403`.

## Roles

Buzz has two independent authority axes after this change.
**Relay-level** roles (new here) are deployment-global: they act across
every community on the relay, through the admin API. **Community-level**
roles (pre-existing, unchanged by this PR) are tenant-scoped: they act
inside one community, through signed Nostr moderation commands.

### Relay level (new)

| Role | Description |
|---|---|
| **Operator** | Full control of the deployment's moderation surface:
read all reports, feedback, and attachments across every community;
resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or
decisions (`dismiss`/`escalate`); reopen and cancel; update feedback
status; and manage the Operator/Moderator roster via the staffing
endpoints. |
| **Moderator** | Day-to-day triage: everything an Operator can do
except staffing — cannot view or change the roster. |

How a pubkey acquires a relay role (resolution order; config always
outranks DB):

1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`)
2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty →
**Operator** (source `owner_fallback`, a break-glass grant for
self-hosters that deactivates once any operator is configured)
3. Row in the `relay_operators` table → **Operator** or **Moderator**
(source `db`, managed via the staffing endpoints)
4. No match → `403`

### Community level (pre-existing, unchanged)

| Role | Description |
|---|---|
| **Owner** (community) | Full authority within their community: every
moderation action (delete, kick, ban/unban, timeout/untimeout, resolve
reports, view queue) plus member, role, and invite management. No guard
rails. |
| **Admin** (community) | Same community-wide moderation capabilities as
owner, except an admin cannot ban or time out the owner or a fellow
admin — only the owner may action an admin. Manages members and invites;
only the owner grants the admin role. |
| **Member** (community) | Standard participant; no moderation
capability. |
| **Owner / Admin** (channel) | Channel-local authority only: delete
messages and kick users within their own channel. |
| **Member / Guest / Bot** (channel) | No moderation authority. |

There is no community-level Moderator tier in v1; relay-level Moderator
is the only role by that name.

## Escalation scoping

The operator report queue is an escalation backstop, not the community's
day-to-day triage surface (per `VISION_MODERATION`, the severe class is
the platform's to review rather than the community's). Two rules enforce
that:

- **Escalated-by-default listing.** `GET /reports` with no `status`
parameter returns only `escalated` reports. An explicit
`status=<open|resolved|dismissed|escalated>` filter is always honored as
given, and full visibility across every status stays available for
platform-safety and legal review via `scope=all` (which lists reports
regardless of status). `scope` accepts only `all` and is ignored when an
explicit `status` is present.
- **Auto-escalated `illegal` reports.** Member reports whose category is
`illegal` are ingested with `status=escalated` rather than `open`, so
the severe class reaches the operator backstop without waiting for a
community admin to forward it. Every other category still lands `open`.
Auto-escalation only sets the queue status — it records no moderator
decision and stamps no resolver, so an auto-escalated report is
indistinguishable downstream from an admin-escalated one: the reopen
route returns it to `open` on the same terms, keyed only on status,
never on how the report became escalated.

## Principal resolution and NIP-98 admission

`resolve_admin_principal()` returns `AdminPrincipal { pubkey, role,
source }` per the resolution order above; `None` never falls through as
a role. Admission is ordered so the replay guard is a privilege, not a
public surface: signature/URL/method/payload-hash verification first,
roster check second, and only then is the deployment-scoped replay id
atomically consumed — a validly-signing but unrostered key never
allocates a replay slot. Redis failure fails closed.

## Report resolution, recovery, and enforcement provenance

`POST /reports/{id}/resolve` is a crash-safe enforcement state machine:
decision-only outcomes (`dismiss`/`escalate`) are a single
CAS-plus-audit transaction; enforcement
(`delete`/`kick`/`ban`/`timeout`) claims the report
(`open`→`processing`), runs the durable mutation, then finalizes — a
re-drive resumes at the step marker and converges to exactly-one
enforcement, fenced by a lease and an outbox claim token.

Person-directed enforcement on an `event`-kind report derives its target
from the stored event's author (server-owned truth, never the reporter's
`p` tag) via a single `derive_enforcement_target` shared by the HTTP
driver and the recovery worker. If the reported event was purged before
its author could be read, person-directed actions are rejected pre-claim
and the report stays `open`; `delete` needs only the event id and is
exempt.

`GET /reports/{id}` and the resolve response carry an `activeAction`
field surfacing the enforcement that actually executed — a report
dismissed after a reopen still reports the ban that ran. `POST
/reports/{id}/reopen` returns a terminal report to `open` (idempotent on
`requestId`). `POST /reports/{id}/cancel` is the sole recovery path for
a pre-mutation `failed` action, attributed via
`relay_admin_actions.cancelled_by`.

## Feedback

`GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance
columns are severed to `NULL` rather than cascade-deleted, and the
attachment path fails closed to `404` on a severed row. `PATCH
/feedback/{id}` updates lifecycle `status`
(`new`/`reviewed`/`archived`).

## Staffing and probe

`GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a
config-backed pubkey returns `409 Conflict`. `GET /operators` returns
the union of config and DB principals with per-entry `source`. `GET
/probe` reports auth mode, role, source, `canAct`, and `canStaff` for
the desktop console.

## NIP-11 auto-discovery

The NIP-11 relay-information document gains an optional `admin_api`
field carrying the canonical admin origin (`scheme://host[:port]`, no
path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The
scheme follows the same loopback rule as NIP-98 `u`-tag verification via
a shared `scheme_for_host` helper, so the advertised origin and the
origin the relay verifies against can never diverge.

## Operator API origin decoupling

`RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
`RELAY_OPERATOR_PUBKEYS` is set — it is used only by the
community-provisioning endpoints, which fail closed at request time
(with a boot-time `WARN`) until it is set. The admin console needs no
origin.

## Admin-web adaptation

The standalone `admin-web` dashboard signs each request as a NIP-98
event via a NIP-07 browser extension, discovers the auth mode with a
single unauthenticated probe (`200` → `disabled`, anything else →
`nip98`, fail-secure), and carries no token entry surface. Playwright
coverage exercises the NIP-98 and CSP paths.

## Security hardening

Three findings from security review are folded in:

- **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}`
mutate the deployment-wide root of trust, but the upsert overwrites
`role`/`added_by` in place and the delete removes the only row — so a
grant→revoke sequence left no trace of who was ever granted or by whom.
Each mutation now writes an `relay_operator_audit` row (actor, target,
`grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside
the same transaction as the mutation. A per-target transaction-scoped
advisory lock serializes concurrent mutations of the same pubkey before
the pre-image read, so the recorded `prev_role` is always the true
predecessor even under a concurrent-grant race. Chronology is keyed on a
`BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock:
the serializing lock guarantees insertion order and `seq` captures it,
so ordered reads (`ORDER BY seq`) follow the true privilege chain even
across a backward NTP step that a `clock_timestamp()` ordering would
invert. `created_at` (`clock_timestamp()`) is retained as informational
occurrence time only. Append-only by construction — no `UPDATE`/`DELETE`
path and no API surface. A no-op delete writes nothing.
- **`expirationSecs` overflow.** The timeout path built `Utc::now() +
Duration::seconds(secs as i64)` from an attacker-controlled `u64`:
`i64::MAX` panicked the handler, and a wrapped-negative magnitude minted
a *past* expiry that still passed validation. `compute_timeout_until`
now rejects zero, rejects magnitudes above a documented
`MAX_TIMEOUT_SECS` (365 days), and uses checked
`try_seconds`/`checked_add_signed` so no input can panic or produce a
past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs
all return a clean `4xx`.
- **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased
at parse, but the `409` immutability check raw-string-compared the path
param while `decode_hex_pubkey` accepted uppercase — so `PUT
/operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the
same 32 bytes. The validated param is now canonicalized (lowercased)
before the `409` check, DB write, `DELETE`, and response body.

## Migrations

- `0035_relay_operators.sql` — `relay_operators` roster table
(deployment-global), `actor_authority` on `moderation_actions`,
`processing` status plus `active_action_id` on `moderation_reports`,
`status` on `product_feedback`.
- `0036_relay_admin_actions.sql` — enforcement-action table with a
`request_id` idempotency key, a `step_marker` for crash recovery, and a
`cancelled_by` attribution column.
- `0037_relay_admin_action_lease.sql` — lease fencing for the action
worker.
- `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the
outbox worker.
- `0039_relay_operator_audit.sql` — append-only `relay_operator_audit`
trail for roster mutations (see Security hardening).

`docs/admin/README.md` documents the full principal model, NIP-98 event
requirements, capabilities by role, the startup error matrix, and the
discovery field.

## Production blast radius

A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin
surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering
`BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where
`BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to
`nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces
`main`'s prior `Host`/`Origin`-only gating but is read-only (mutation
routes `403`). The five migrations add tables and columns without
touching existing data.

---

Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768)
(desktop admin console consuming the `admin_api` field),
[squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339)
(Phase 4 rollout config)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-28 11:43:05 -04:00
committed by GitHub
parent e76c81968b
commit 86b9142a09
45 changed files with 18346 additions and 440 deletions

View File

@@ -6,9 +6,17 @@ import {
useMemo,
useState,
} from "react";
import { ApiFailure, request } from "./api";
import {
type AuthMode,
ApiFailure,
mutate,
probeAuthMode,
request,
requestObjectUrl,
} from "./api";
import type {
FeedbackDetail,
FeedbackStatus,
FeedbackSummary,
Report,
ReportDetail as ReportDetailData,
@@ -85,9 +93,9 @@ function StateView<T>({
return resource.data ? children(resource.data) : null;
}
function Reports() {
function Reports({ authMode }: { authMode: AuthMode }) {
const resource = useResource(
() => request<Report[]>("/reports?status=open&limit=100"),
() => request<Report[]>("/reports?status=open&limit=100", authMode),
"reports",
);
return (
@@ -135,9 +143,9 @@ function Reports() {
);
}
function ReportDetail({ id }: { id: string }) {
function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) {
const resource = useResource(
() => request<ReportDetailData>(`/reports/${id}`),
() => request<ReportDetailData>(`/reports/${id}`, authMode),
id,
);
return (
@@ -210,28 +218,29 @@ function ReportDetail({ id }: { id: string }) {
);
}
function FeedbackList() {
function FeedbackList({ authMode }: { authMode: AuthMode }) {
const resource = useResource(
() => request<FeedbackSummary[]>("/feedback"),
() => request<FeedbackSummary[]>("/feedback", authMode),
"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);
// Successful PATCH responses override the server-loaded status so the row
// reflects the new value without a full refetch. Keyed by feedback id.
const [overrides, setOverrides] = useState<Record<string, FeedbackStatus>>(
{},
);
const updateStatus = (id: string, event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
setStatuses((current) => {
const next = {
...current,
[id]: checked,
};
saveFeedbackStatuses(next);
return next;
});
};
// Mutations require a named principal; disabled mode's server rejects them
// (probe reports canAct: false), so the write control is hidden there rather
// than offering an action that can only fail.
const canWrite = authMode === "nip98";
const applyStatus = useCallback((id: string, status: FeedbackStatus) => {
setOverrides((current) => ({ ...current, [id]: status }));
}, []);
return (
<Page
@@ -249,7 +258,7 @@ function FeedbackList() {
community={community}
timeRange={timeRange}
statusFilter={statusFilter}
statuses={statuses}
overrides={overrides}
>
{({ communities, filtered }) => (
<>
@@ -301,8 +310,9 @@ function FeedbackList() {
}
>
<option value="all">Any status</option>
<option value="pending">Needs action</option>
<option value="acted-on">Acted on</option>
<option value="new">New</option>
<option value="reviewed">Reviewed</option>
<option value="archived">Archived</option>
</select>
</label>
</div>
@@ -327,22 +337,18 @@ function FeedbackList() {
<CategoryTag category={item.category} />
<strong>{item.bodySummary}</strong>
<span className="record-provenance">
{item.communityHost}
<Provenance host={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>
<FeedbackStatusControl
id={item.id}
status={overrides[item.id] ?? item.status}
authMode={authMode}
canWrite={canWrite}
onApplied={applyStatus}
/>
<div className="record-date">
<span>Received</span>
<time>{date(item.receivedAt)}</time>
@@ -352,7 +358,7 @@ function FeedbackList() {
className="record-open-link"
>
<span className="visually-hidden">
Open feedback from {item.communityHost}
Open feedback from {hostLabel(item.communityHost)}
</span>
<ArrowIcon />
</Link>
@@ -372,13 +378,101 @@ function FeedbackList() {
);
}
const STATUS_LABELS: Record<FeedbackStatus, string> = {
new: "New",
reviewed: "Reviewed",
archived: "Archived",
};
const STATUS_OPTIONS: FeedbackStatus[] = ["new", "reviewed", "archived"];
/// The per-row lifecycle control. In writable mode it PATCHes the relay and
/// adopts the status from the PATCH response (never optimistically — a failed
/// write leaves the prior status and surfaces an error). In read-only mode it
/// shows the authoritative status as a static badge, since disabled-mode
/// mutations are rejected server-side.
function FeedbackStatusControl({
id,
status,
authMode,
canWrite,
onApplied,
}: {
id: string;
status: FeedbackStatus;
authMode: AuthMode;
canWrite: boolean;
onApplied: (id: string, status: FeedbackStatus) => void;
}) {
const [pending, setPending] = useState(false);
const [error, setError] = useState(false);
if (!canWrite) {
return (
<span className="feedback-status">
<span className={`status status-${status}`}>
{STATUS_LABELS[status]}
</span>
</span>
);
}
const onChange = async (event: ChangeEvent<HTMLSelectElement>) => {
const next = event.target.value as FeedbackStatus;
setPending(true);
setError(false);
try {
const updated = await mutate<{ status: FeedbackStatus }>(
`/feedback/${encodeURIComponent(id)}`,
"PATCH",
{ status: next },
authMode,
);
onApplied(id, updated.status);
} catch {
setError(true);
} finally {
setPending(false);
}
};
return (
<label className="feedback-status">
<span className="visually-hidden">Status</span>
<select value={status} onChange={onChange} disabled={pending}>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{STATUS_LABELS[option]}
</option>
))}
</select>
{error ? (
<span className="status-error" role="alert">
Update failed
</span>
) : null}
</label>
);
}
/// Feedback whose source community was purged carries no host. Render an
/// explicit provenance-unavailable marker rather than an empty slot.
function Provenance({ host }: { host: string | null }) {
if (host) return host;
return <span className="provenance-unavailable">Community unavailable</span>;
}
function hostLabel(host: string | null): string {
return host ?? "an unavailable community";
}
function FeedbackResults({
items,
query,
community,
timeRange,
statusFilter,
statuses,
overrides,
children,
}: {
items: FeedbackSummary[];
@@ -386,7 +480,7 @@ function FeedbackResults({
community: string;
timeRange: string;
statusFilter: string;
statuses: FeedbackStatuses;
overrides: Record<string, FeedbackStatus>;
children: (results: {
communities: string[];
filtered: FeedbackSummary[];
@@ -394,14 +488,14 @@ function FeedbackResults({
}) {
const results = useMemo(() => {
const communities = [...new Set(items.map((item) => item.communityHost))]
.filter(Boolean)
.filter((host): host is string => Boolean(host))
.sort((left, right) => left.localeCompare(right));
const normalizedQuery = query.trim().toLocaleLowerCase();
const after = timeRangeStart(timeRange);
const filtered = items.filter((item) => {
const status = overrides[item.id] ?? item.status;
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 (statusFilter !== "all" && status !== statusFilter) return false;
if (after !== undefined) {
const receivedAt = new Date(item.receivedAt).valueOf();
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
@@ -409,19 +503,25 @@ function FeedbackResults({
if (!normalizedQuery) return true;
return [
item.bodySummary,
item.communityHost,
item.communityHost ?? "",
item.category ?? "uncategorized",
item.submitterPubkey,
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
});
return { communities, filtered };
}, [items, query, community, timeRange, statusFilter, statuses]);
}, [items, query, community, timeRange, statusFilter, overrides]);
return children(results);
}
function FeedbackDetailView({ id }: { id: string }) {
function FeedbackDetailView({
id,
authMode,
}: {
id: string;
authMode: AuthMode;
}) {
const resource = useResource(
() => request<FeedbackDetail>(`/feedback/${id}`),
() => request<FeedbackDetail>(`/feedback/${id}`, authMode),
id,
);
return (
@@ -434,11 +534,16 @@ function FeedbackDetailView({ id }: { id: string }) {
>
<StateView resource={resource}>
{(feedback) => {
const attachments = feedbackAttachments(
feedback.id,
feedback.tags,
feedback.communityHost,
);
// Without an authoritative host we cannot validate attachment URLs or
// derive their fetch paths safely, so we render none and mark the
// provenance unavailable rather than guessing an origin.
const attachments = feedback.communityHost
? feedbackAttachments(
feedback.id,
feedback.tags,
feedback.communityHost,
)
: [];
const body = stripAttachmentMarkdown(feedback.body, attachments);
return (
<article className="detail">
@@ -448,7 +553,9 @@ function FeedbackDetailView({ id }: { id: string }) {
</span>
<div>
<CategoryTag category={feedback.category} />
<h2>{feedback.communityHost}</h2>
<h2>
<Provenance host={feedback.communityHost} />
</h2>
</div>
</div>
<dl>
@@ -460,8 +567,9 @@ function FeedbackDetailView({ id }: { id: string }) {
<dd className="attachments">
{attachments.map((attachment) => (
<Attachment
key={`${attachment.hash}-${attachment.url}`}
key={`${attachment.hash}-${attachment.path}`}
attachment={attachment}
authMode={authMode}
/>
))}
</dd>
@@ -488,10 +596,8 @@ function FeedbackDetailView({ id }: { id: string }) {
);
}
type FeedbackStatuses = Record<string, boolean>;
interface FeedbackAttachment {
url: string;
path: string;
sourceUrl: string;
mimeType: string;
hash: string;
@@ -500,25 +606,6 @@ interface FeedbackAttachment {
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,
@@ -551,7 +638,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,
@@ -595,8 +682,17 @@ function stripAttachmentMarkdown(
.trim();
}
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
const url = attachment.url;
function Attachment({
attachment,
authMode,
}: {
attachment: FeedbackAttachment;
authMode: AuthMode;
}) {
const [objectUrl, setObjectUrl] = useState<string>();
const [objectType, setObjectType] = useState<string>();
const [failed, setFailed] = useState(false);
const path = attachment.path;
const name =
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
const metadata = [
@@ -607,33 +703,74 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
.filter(Boolean)
.join(" · ");
if (attachment.mimeType.startsWith("image/")) {
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);
setObjectType(undefined);
setFailed(false);
requestObjectUrl(path, authMode)
.then((created) => {
if (!active) {
URL.revokeObjectURL(created.url);
return;
}
url = created.url;
setObjectUrl(created.url);
setObjectType(created.type);
})
.catch(() => {
if (active) setFailed(true);
});
return () => {
active = false;
if (url) URL.revokeObjectURL(url);
};
}, [path, authMode]);
const detail = failed ? "Could not load attachment" : metadata;
// Inline rendering is gated on the server-VERIFIED blob type, never the
// reporter-supplied `attachment.mimeType`: the relay only labels sniffed
// passive raster images as `image/*` and forces everything else to
// `application/octet-stream`, so a hostile payload can never render inline.
if (objectUrl && objectType?.startsWith("image/")) {
return (
<figure className="image-attachment">
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={name} loading="lazy" />
<a href={objectUrl} target="_blank" rel="noreferrer">
<img src={objectUrl} alt={name} />
</a>
<figcaption>
<span>{name}</span>
<small>{metadata}</small>
<small>{detail}</small>
</figcaption>
</figure>
);
}
return (
<a
className="file-attachment"
href={url}
target="_blank"
rel="noreferrer"
download={name}
>
const label = (
<>
<FileIcon />
<span>
<strong>{name}</strong>
<small>{metadata}</small>
<small>{detail}</small>
</span>
</>
);
// 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 <div className="file-attachment">{label}</div>;
// Non-image (or not-yet-typed) payloads are download-only. The object URL
// already wraps `application/octet-stream` bytes, and the `download`
// attribute with no `target="_blank"` means clicking saves the file rather
// than navigating to a typed document on the admin origin.
return (
<a className="file-attachment" href={objectUrl} download={name}>
{label}
<ArrowIcon />
</a>
);
@@ -797,18 +934,72 @@ function ArrowIcon() {
);
}
/// Shown in nip98 mode when no NIP-07 extension is available. Instructs the
/// operator to install nos2x or Alby before continuing.
function Nip07Screen() {
return (
<div className="app">
<div className="state auth-prompt">
<h2>Nostr extension required</h2>
<p>
This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension
such as{" "}
<a
href="https://github.com/fiatjaf/nos2x"
target="_blank"
rel="noreferrer"
>
nos2x
</a>{" "}
or{" "}
<a href="https://getalby.com" target="_blank" rel="noreferrer">
Alby
</a>
, then reload this page. Your Nostr key will be used to sign each
request.
</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</div>
</div>
);
}
export function App() {
const { path } = usePath();
// Probe the relay once to discover the auth mode. `null` means the probe is
// still in flight. Once resolved, the mode is stable for the session.
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
useEffect(() => {
let active = true;
probeAuthMode().then((mode) => {
if (active) setAuthMode(mode);
});
return () => {
active = false;
};
}, []);
const report = path.match(/^\/reports\/([^/]+)$/);
const feedback = path.match(/^\/feedback\/([^/]+)$/);
// Probe still in flight — render nothing to avoid a visible flash.
if (authMode === null) return null;
// NIP-98 mode: require a NIP-07 extension.
if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr)
return <Nip07Screen />;
const content = report ? (
<ReportDetail id={report[1]} />
<ReportDetail id={report[1]} authMode={authMode} />
) : feedback ? (
<FeedbackDetailView id={feedback[1]} />
<FeedbackDetailView id={feedback[1]} authMode={authMode} />
) : path === "/feedback" ? (
<FeedbackList />
<FeedbackList authMode={authMode} />
) : (
<Reports />
<Reports authMode={authMode} />
);
return (
<div className="app">

View File

@@ -9,11 +9,114 @@ export class ApiFailure extends Error {
}
}
export async function request<T>(path: string): Promise<T> {
const response = await fetch(`${PREFIX}${path}`, {
credentials: "same-origin",
headers: { accept: "application/json" },
/// The authentication mode the relay requires, discovered via the probe.
/// - `nip98` — NIP-98 HTTP Auth; each request signed with a NIP-07 extension
/// - `disabled` — relay returned 200 with no auth; no credential needed
export type AuthMode = "nip98" | "disabled";
/// Sign a NIP-98 kind-27235 event for the given URL + method via window.nostr.
/// Throws if window.nostr is not available or signing fails.
///
/// A fresh random `nonce` tag is generated on every call. Without it, two
/// same-URL requests in the same second produce byte-identical events (the
/// signed fields are only `u`, `method`, and `created_at` at 1-second
/// resolution), so their event IDs collide — the relay's NIP-98 replay guard
/// rejects the second, and the 401 retry re-signs the same fields and can
/// never recover. The verifier ignores unknown tags, so the nonce is inert to
/// verification and serves only to make each signed event unique.
///
/// For body-bearing methods the caller passes `body`; a `payload` tag carrying
/// the hex SHA-256 of the exact bytes is added and signed. The relay's verifier
/// rejects a body-bearing request whose `payload` tag is absent or mismatched
/// (auth.rs), so the hash must be over the identical bytes the request sends.
async function signNip98(
url: string,
method: string,
body?: Uint8Array,
): Promise<string> {
const nostr = (window as Window & typeof globalThis & { nostr?: Nostr98 })
.nostr;
if (!nostr) throw new Error("No NIP-07 extension available");
const nonce = crypto.getRandomValues(new Uint8Array(16));
const nonceHex = toHex(nonce);
const tags: string[][] = [
["u", url],
["method", method],
["nonce", nonceHex],
];
if (body !== undefined) {
tags.push(["payload", await sha256Hex(body)]);
}
const event = await nostr.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
});
return `Nostr ${btoa(JSON.stringify(event))}`;
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
return toHex(new Uint8Array(digest));
}
// Minimal type for the NIP-07 window.nostr interface.
interface Nostr98 {
signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
}): Promise<Record<string, unknown>>;
}
/// Every admin API call goes through here. Attaches the correct credential
/// for the current auth mode:
/// - `nip98` mode: sign a kind-27235 event via NIP-07 for each request
/// - `disabled` mode: no credential
///
/// A 401 in nip98 mode re-signs once (handles key rotation / clock skew)
/// and retries the request exactly once; a second 401 surfaces the error —
/// no infinite loop.
async function send(
path: string,
accept: string,
authMode: AuthMode,
init?: { method?: string; body?: Uint8Array; contentType?: string },
): Promise<Response> {
const method = init?.method ?? "GET";
const body = init?.body;
const doRequest = async () => {
const headers: Record<string, string> = { accept };
if (init?.contentType) headers["content-type"] = init.contentType;
if (authMode === "nip98") {
const url = `${location.protocol}//${location.host}${PREFIX}${path}`;
headers.authorization = await signNip98(url, method, body);
}
return fetch(`${PREFIX}${path}`, {
method,
credentials: "same-origin",
headers,
body: body as BodyInit | undefined,
});
};
let response = await doRequest();
if (response.status === 401 && authMode === "nip98") {
// Re-sign with a fresh event and retry exactly once (handles clock
// skew or key rotation). A second 401 surfaces the error below.
response = await doRequest();
}
if (response.status === 401) {
throw new ApiFailure(401, "The admin credential was rejected.");
}
if (!response.ok) {
const envelope = await response.json().catch(() => null);
throw new ApiFailure(
@@ -21,5 +124,62 @@ export async function request<T>(path: string): Promise<T> {
envelope?.error?.message ?? `Request failed (${response.status})`,
);
}
return response;
}
export async function request<T>(path: string, authMode: AuthMode): Promise<T> {
const response = await send(path, "application/json", authMode);
return response.json() as Promise<T>;
}
/// Send a body-bearing mutation (PATCH/PUT/POST) and parse the JSON response.
/// The body is serialized once and signed over those exact bytes so the NIP-98
/// `payload` tag matches what the relay verifies.
export async function mutate<T>(
path: string,
method: string,
body: unknown,
authMode: AuthMode,
): Promise<T> {
const bytes = new TextEncoder().encode(JSON.stringify(body));
const response = await send(path, "application/json", authMode, {
method,
body: bytes,
contentType: "application/json",
});
return response.json() as Promise<T>;
}
/// Probe whether the relay requires authentication.
/// Issues one unauthenticated request:
/// - 200 → `disabled` (no auth needed)
/// - anything else → `nip98` (the only authenticated mode; fail-secure)
export async function probeAuthMode(): Promise<AuthMode> {
try {
const response = await fetch(`${PREFIX}/reports`, {
credentials: "same-origin",
headers: { accept: "application/json" },
});
return response.status === 200 ? "disabled" : "nip98";
} catch {
// Network error — default to nip98 so a credential is required.
return "nip98";
}
}
/// Attachments cannot be fetched by `<img src>` or `<a href>` because those
/// carry no Authorization header. Callers render the object URL and must
/// revoke it when it is replaced or unmounted.
///
/// Returns the server-verified `type` alongside the URL. The relay sniffs the
/// stored bytes and only labels verified passive raster images as `image/*`;
/// everything else is `application/octet-stream`. Callers must decide inline
/// rendering from this type, never from the untrusted reporter-supplied MIME.
export async function requestObjectUrl(
path: string,
authMode: AuthMode,
): Promise<{ url: string; type: string }> {
const response = await send(path, "*/*", authMode);
const blob = await response.blob();
return { url: URL.createObjectURL(blob), type: blob.type };
}

View File

@@ -304,14 +304,31 @@ h1 {
gap: 0.4rem;
color: rgb(35 30 30 / 62%);
font-size: 0.78rem;
}
.feedback-status select {
font: inherit;
padding: 0.25rem 0.4rem;
border-radius: 0.4rem;
border: 1px solid rgb(35 30 30 / 18%);
background: #fff;
color: #231e1e;
cursor: pointer;
}
.feedback-status input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #231e1e;
.feedback-status select:disabled {
opacity: 0.55;
cursor: progress;
}
.status-error {
color: #b3261e;
font-size: 0.72rem;
}
.provenance-unavailable {
font-style: italic;
color: rgb(35 30 30 / 45%);
}
.record-open-link {
@@ -670,6 +687,18 @@ dd {
color: #9f2424;
}
.auth-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);

View File

@@ -23,25 +23,33 @@ export interface ReportDetail extends Report {
message: ReportedMessage | null;
}
export type FeedbackStatus = "new" | "reviewed" | "archived";
export interface FeedbackSummary {
id: string;
communityId: string;
communityHost: string;
/// `null` once the source community is purged (provenance severed).
communityId: string | null;
/// `null` when `communityId` is severed — feedback retained without origin.
communityHost: string | null;
submitterPubkey: string;
category?: string;
bodySummary: string;
status: FeedbackStatus;
receivedAt: string;
}
export interface FeedbackDetail {
id: string;
communityId: string;
communityHost: string;
/// `null` once the source community is purged (provenance severed).
communityId: string | null;
/// `null` when `communityId` is severed — feedback retained without origin.
communityHost: string | null;
eventId: string;
submitterPubkey: string;
category?: string;
body: string;
tags: string[][];
status: FeedbackStatus;
eventCreatedAt: string;
receivedAt: string;
}