Community moderation UI: integration branch (data layer + member surface base) (#1617)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-08 13:49:54 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d Tyler Longwell npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent 863aeb79f3
commit c9c868b250
31 changed files with 3360 additions and 115 deletions
+375
View File
@@ -0,0 +1,375 @@
import { relayClient } from "@/shared/api/relayClient";
import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
import {
KIND_MODERATION_BAN,
KIND_MODERATION_RESOLVE_REPORT,
KIND_MODERATION_TIMEOUT,
KIND_MODERATION_UNBAN,
KIND_MODERATION_UNTIMEOUT,
KIND_REPORT,
} from "@/shared/constants/kinds";
// Community-moderation data layer. Writes are signed Nostr events published over
// the same WebSocket path as every other desktop write (mirrors relayMembers.ts);
// reads are NIP-98-authed HTTP GETs to /moderation/*, which have no WS equivalent.
//
// Wire contract is pinned by the relay/CLI (buzz-sdk builders, report.rs,
// api/bridge.rs). Command events (9040–9044) carry NO `h` tag — the relay binds
// the tenant from the connection host, and a stray `h` is rejected as
// channel-scoping a global-only command.
const NIP98_KIND = 27235;
/** NIP-56 report categories (report.rs `REPORT_TYPES`). */
export type ReportType =
| "illegal"
| "nudity"
| "malware"
| "spam"
| "impersonation"
| "profanity"
| "other";
/** A moderator's disposition of a queued report (buzz-sdk resolve builder). */
export type ResolutionStatus = "resolved" | "dismissed";
export type ResolutionAction =
| "delete"
| "kick"
| "ban"
| "timeout"
| "dismiss"
| "escalate";
// --- Read row shapes (api/bridge.rs report_json / action_json / ban_json) ---
export type ModerationReport = {
id: string;
reportEventId: string;
reporterPubkey: string;
targetKind: "event" | "pubkey" | "blob";
target: string;
channelId: string | null;
reportType: string;
note: string | null;
status: string;
resolvedBy: string | null;
resolvedAt: string | null;
actionId: string | null;
createdAt: string;
};
export type ModerationAction = {
id: string;
actorPubkey: string;
action: string;
targetPubkey: string | null;
targetEventId: string | null;
channelId: string | null;
reasonCode: string | null;
publicReason: string | null;
privateReason: string | null;
matchedPrincipal: string | null;
createdAt: string;
};
export type CommunityRestriction = {
pubkey: string;
banned: boolean;
banExpiresAt: string | null;
banReason: string | null;
mutedUntil: string | null;
muteReason: string | null;
actorPubkey: string;
updatedAt: string;
};
function normalizePubkey(pubkey: string): string {
return pubkey.trim().toLowerCase();
}
// --- Writes: signed events over the WS publish path ---
async function publishModerationEvent(
kind: number,
tags: string[][],
timeoutMessage: string,
errorMessage: string,
): Promise<void> {
const event = await signRelayEvent({ kind, content: "", tags });
await relayClient.publishEvent(event, timeoutMessage, errorMessage);
}
/**
* Submit a NIP-56 report (kind:1984). Member-facing report entry always targets
* a message, so both the author `p` tag and the `e` (event) tag are carried; the
* report type rides the `e` tag's third element per report.rs `parse_report`.
*/
export async function submitReport(input: {
authorPubkey: string;
eventId: string;
reportType: ReportType;
note?: string;
}): Promise<void> {
const tags: string[][] = [
["p", normalizePubkey(input.authorPubkey)],
["e", input.eventId, input.reportType],
];
const event = await signRelayEvent({
kind: KIND_REPORT,
content: input.note?.trim() ? input.note.trim() : "",
tags,
});
await relayClient.publishEvent(
event,
"Timed out while submitting the report.",
"Failed to submit the report.",
);
}
/** Ban a member (kind:9040). `expiresAt` unix-secs ⇒ temporary; omit ⇒ permanent. */
export async function banMember(input: {
pubkey: string;
expiresAt?: number;
reason?: string;
}): Promise<void> {
const tags: string[][] = [["p", normalizePubkey(input.pubkey)]];
if (input.expiresAt != null)
tags.push(["expiration", String(input.expiresAt)]);
if (input.reason?.trim()) tags.push(["reason", input.reason.trim()]);
await publishModerationEvent(
KIND_MODERATION_BAN,
tags,
"Timed out while banning the member.",
"Failed to ban the member.",
);
}
/** Lift a ban (kind:9041). */
export async function unbanMember(pubkey: string): Promise<void> {
await publishModerationEvent(
KIND_MODERATION_UNBAN,
[["p", normalizePubkey(pubkey)]],
"Timed out while lifting the ban.",
"Failed to lift the ban.",
);
}
/** Time out a member (kind:9042). `expiresAt` unix-secs is required by the relay. */
export async function timeoutMember(input: {
pubkey: string;
expiresAt: number;
reason?: string;
}): Promise<void> {
const tags: string[][] = [
["p", normalizePubkey(input.pubkey)],
["expiration", String(input.expiresAt)],
];
if (input.reason?.trim()) tags.push(["reason", input.reason.trim()]);
await publishModerationEvent(
KIND_MODERATION_TIMEOUT,
tags,
"Timed out while applying the timeout.",
"Failed to apply the timeout.",
);
}
/** Lift a timeout (kind:9043). */
export async function untimeoutMember(pubkey: string): Promise<void> {
await publishModerationEvent(
KIND_MODERATION_UNTIMEOUT,
[["p", normalizePubkey(pubkey)]],
"Timed out while lifting the timeout.",
"Failed to lift the timeout.",
);
}
/**
* Resolve a queued report (kind:9044). `dismiss` pairs with `dismissed`; every
* other action pairs with `resolved` (the relay enforces the pairing). `reason`
* is moderator-authored and reporter-readable — it lands in the public tombstone
* and the reporter-notice DM.
*/
export async function resolveReport(input: {
reportEventId: string;
status: ResolutionStatus;
action: ResolutionAction;
reason?: string;
}): Promise<void> {
const tags: string[][] = [
["report", normalizePubkey(input.reportEventId)],
["status", input.status],
["action", input.action],
];
if (input.reason?.trim()) tags.push(["reason", input.reason.trim()]);
await publishModerationEvent(
KIND_MODERATION_RESOLVE_REPORT,
tags,
"Timed out while resolving the report.",
"Failed to resolve the report.",
);
}
// --- Reads: NIP-98-authed HTTP GETs ---
/**
* Build the NIP-98 `Authorization` header for a GET.
*
* The relay verifies the signed `u` tag against the full request URL including
* the query string (the read-auth fix), so the URL is finalized by the caller
* *before* signing and this function never appends parameters afterward — the
* signed `u` and the fetched URL are guaranteed identical.
*/
async function nip98GetHeader(url: string): Promise<string> {
const authEvent = await signRelayEvent({
kind: NIP98_KIND,
content: "",
tags: [
["u", url],
["method", "GET"],
["nonce", crypto.randomUUID()],
],
});
// NIP-98 events carry empty content and ASCII-only tags, so btoa is safe here.
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
}
async function moderationGet<T>(pathWithQuery: string): Promise<T> {
const base = (await getRelayHttpUrl()).replace(/\/+$/, "");
const url = `${base}${pathWithQuery}`;
const authorization = await nip98GetHeader(url);
const response = await fetch(url, {
headers: { Authorization: authorization },
});
if (!response.ok) {
throw new Error(
`Moderation request failed (${response.status}): ${pathWithQuery}`,
);
}
return (await response.json()) as T;
}
type RawReport = {
id: string;
report_event_id: string;
reporter_pubkey: string;
target_kind: "event" | "pubkey" | "blob";
target: string;
channel_id: string | null;
report_type: string;
note: string | null;
status: string;
resolved_by: string | null;
resolved_at: string | null;
action_id: string | null;
created_at: string;
};
type RawAction = {
id: string;
actor_pubkey: string;
action: string;
target_pubkey: string | null;
target_event_id: string | null;
channel_id: string | null;
reason_code: string | null;
public_reason: string | null;
private_reason: string | null;
matched_principal: string | null;
created_at: string;
};
type RawRestriction = {
pubkey: string;
banned: boolean;
ban_expires_at: string | null;
ban_reason: string | null;
muted_until: string | null;
mute_reason: string | null;
actor_pubkey: string;
updated_at: string;
};
function toReport(r: RawReport): ModerationReport {
return {
id: r.id,
reportEventId: r.report_event_id,
reporterPubkey: r.reporter_pubkey,
targetKind: r.target_kind,
target: r.target,
channelId: r.channel_id,
reportType: r.report_type,
note: r.note,
status: r.status,
resolvedBy: r.resolved_by,
resolvedAt: r.resolved_at,
actionId: r.action_id,
createdAt: r.created_at,
};
}
function toAction(a: RawAction): ModerationAction {
return {
id: a.id,
actorPubkey: a.actor_pubkey,
action: a.action,
targetPubkey: a.target_pubkey,
targetEventId: a.target_event_id,
channelId: a.channel_id,
reasonCode: a.reason_code,
publicReason: a.public_reason,
privateReason: a.private_reason,
matchedPrincipal: a.matched_principal,
createdAt: a.created_at,
};
}
function toRestriction(b: RawRestriction): CommunityRestriction {
return {
pubkey: b.pubkey,
banned: b.banned,
banExpiresAt: b.ban_expires_at,
banReason: b.ban_reason,
mutedUntil: b.muted_until,
muteReason: b.mute_reason,
actorPubkey: b.actor_pubkey,
updatedAt: b.updated_at,
};
}
/**
* Fetch the moderation queue (`GET /moderation/reports`). Mod-authz gated —
* ordinary members receive 403. `status` filters (e.g. "open"); `limit` is
* clamped relay-side.
*/
export async function listReports(options?: {
status?: string;
limit?: number;
}): Promise<ModerationReport[]> {
const params = new URLSearchParams();
if (options?.limit != null) params.set("limit", String(options.limit));
if (options?.status) params.set("status", options.status);
const query = params.toString();
const rows = await moderationGet<RawReport[]>(
query ? `/moderation/reports?${query}` : "/moderation/reports",
);
return rows.map(toReport);
}
/** Fetch the audit log (`GET /moderation/audit`), newest-first. Mod-authz gated. */
export async function listAuditActions(
limit?: number,
): Promise<ModerationAction[]> {
const query = limit != null ? `?limit=${limit}` : "";
const rows = await moderationGet<RawAction[]>(`/moderation/audit${query}`);
return rows.map(toAction);
}
/**
* Fetch active bans/timeouts (`GET /moderation/restricted`). Mod-authz gated —
* this is the moderator's view of who is currently restricted, not a member's
* self-state lookup.
*/
export async function listRestrictions(): Promise<CommunityRestriction[]> {
const rows = await moderationGet<RawRestriction[]>("/moderation/restricted");
return rows.map(toRestriction);
}