mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Tyler Longwell
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent
863aeb79f3
commit
c9c868b250
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user