Repository Public MIT
1import { audit } from "./audit";2import { isPlatformAdmin, requireScope, requireUser } from "./auth";3import { userOf, type Ctx } from "./context";4import { ApiError, badRequest, forbidden, notFound } from "./errors";5import { artifactDto, getArtifactRow, recomputeLatest, type ArtifactRow } from "./registry";67function requireModerator(ctx: Ctx) {8 requireScope(ctx.principal, "admin:moderate");9 const p = ctx.principal;10 if (p.kind === "token" && !p.token.elevated) throw forbidden("Moderation requires an elevated token.", "admin_required");11 return requireUser(p);12}1314/* ───────────────────────── reports ───────────────────────── */1516export const REPORT_CATEGORIES = ["malware", "secret-leak", "license", "impersonation", "spam", "broken", "other"] as const;1718export async function createReport(ctx: Ctx, input: { artifact?: string; version?: string; category?: string; details?: string; contact?: string }) {19 if (!(REPORT_CATEGORIES as readonly string[]).includes(String(input.category))) throw badRequest("category is invalid.", { validCategories: REPORT_CATEGORIES });20 const details = String(input.details ?? "").trim();21 if (details.length < 20 || details.length > 5000) throw badRequest("details must be 20-5000 characters.");22 const m = /^@?([a-z0-9-]{1,39})\/([a-z0-9-]{1,64})$/.exec(String(input.artifact ?? ""));23 if (!m) throw badRequest("artifact must look like @namespace/slug.");24 const a = await getArtifactRow(ctx, m[1]!, m[2]!);25 let releaseId: string | null = null;26 if (input.version) {27 const [r] = await ctx.sql<{ id: string }[]>`SELECT id FROM releases WHERE artifact_id = ${a.id} AND version = ${String(input.version).slice(0, 64)}`;28 if (!r) throw notFound("Release");29 releaseId = r.id;30 }31 const contact = input.contact ? String(input.contact).trim().slice(0, 200) : null;32 const [row] = await ctx.sql<{ id: string; created_at: Date }[]>`33 INSERT INTO reports (artifact_id, release_id, category, details, reporter_user_id, reporter_contact)34 VALUES (${a.id}, ${releaseId}, ${input.category!}, ${details}, ${userOf(ctx.principal)?.id ?? null}, ${contact}) RETURNING id, created_at`;35 await audit(ctx, { action: "report.create", targetType: "artifact", targetId: a.id, targetLabel: `@${a.namespace}/${a.slug}`, detail: { reportId: row!.id, category: input.category } });36 return { id: row!.id, state: "open", createdAt: row!.created_at };37}3839export async function listReports(ctx: Ctx, state?: string) {40 requireModerator(ctx);41 return ctx.sql<{ id: string; category: string; details: string; state: string; reporter_contact: string | null; created_at: Date; resolution_note: string | null; namespace: string; slug: string; version: string | null; artifact_state: string; artifact_id: string; release_id: string | null }[]>`42 SELECT r.id, r.category, r.details, r.state, r.reporter_contact, r.created_at, r.resolution_note, a.namespace, a.slug, a.state AS artifact_state, a.id AS artifact_id, r.release_id, rel.version43 FROM reports r JOIN artifacts a ON a.id = r.artifact_id LEFT JOIN releases rel ON rel.id = r.release_id44 ${state ? ctx.sql`WHERE r.state = ${state}` : ctx.sql``} ORDER BY (r.state = 'open') DESC, r.created_at DESC LIMIT 200`;45}4647export async function resolveReport(ctx: Ctx, id: string, input: { state: string; note: string }) {48 const user = requireModerator(ctx);49 if (!["triaged", "resolved", "dismissed"].includes(input.state)) throw badRequest("state must be triaged, resolved or dismissed.");50 const note = String(input.note ?? "").trim().slice(0, 2000);51 if (note.length < 5) throw badRequest("A resolution note is required.");52 if (!/^[0-9a-f-]{36}$/.test(id)) throw notFound("Report");53 const [r] = await ctx.sql<{ id: string }[]>`UPDATE reports SET state = ${input.state}, resolution_note = ${note}, resolved_by = ${user.id}, resolved_at = now() WHERE id = ${id} RETURNING id`;54 if (!r) throw notFound("Report");55 await audit(ctx, { action: "report.resolve", targetType: "report", targetId: id, detail: { state: input.state, note } });56 return { id, state: input.state };57}5859/* ───────────────────────── moderation ───────────────────────── */6061export async function moderateArtifact(ctx: Ctx, artifactId: string, input: { action: string; reason: string; confirm?: boolean; reportId?: string }): Promise<ArtifactRow> {62 const user = requireModerator(ctx);63 if (!/^[0-9a-f-]{36}$/.test(artifactId)) throw notFound("Artifact");64 const [a] = await ctx.sql<{ id: string; namespace: string; slug: string; state: string }[]>`SELECT id, namespace, slug, state FROM artifacts WHERE id = ${artifactId}`;65 if (!a) throw notFound("Artifact");66 const reason = String(input.reason ?? "").trim().slice(0, 1000);67 if (reason.length < 5) throw badRequest("A reason is required for every moderation action.");68 if (!["takedown", "restore", "verify-source", "unverify-source"].includes(input.action)) throw badRequest("Unknown moderation action.");69 if (input.action === "takedown" && input.confirm !== true) throw new ApiError(428, "confirmation_required", "Taking down an artifact hides it from everyone. Resend with confirm: true.");70 await ctx.sql.begin(async (tx) => {71 if (input.action === "takedown") await tx`UPDATE artifacts SET state = 'taken_down', deprecation_reason = ${reason}, updated_at = now() WHERE id = ${a.id}`;72 else if (input.action === "restore") {73 await tx`UPDATE artifacts SET state = 'draft', deprecation_reason = NULL, updated_at = now() WHERE id = ${a.id} AND state = 'taken_down'`;74 await recomputeLatest(tx as never, a.id);75 } else await tx`UPDATE artifacts SET source_verified = ${input.action === "verify-source"}, updated_at = now() WHERE id = ${a.id}`;76 await tx`INSERT INTO moderation_actions (actor_user_id, action, target_type, target_id, reason, report_id) VALUES (${user.id}, ${`artifact.${input.action}`}, 'artifact', ${a.id}, ${reason}, ${input.reportId ?? null})`;77 await audit({ ...ctx, sql: tx as never }, { action: `moderation.artifact.${input.action}`, targetType: "artifact", targetId: a.id, targetLabel: `@${a.namespace}/${a.slug}`, detail: { reason, from: a.state } });78 });79 return getArtifactRow({ ...ctx, principal: { kind: "local", user } }, a.namespace, a.slug);80}8182export async function listQuarantined(ctx: Ctx) {83 requireModerator(ctx);84 return ctx.sql<{ id: string; version: string; namespace: string; slug: string; published_at: Date; sha256: string; findings: unknown }[]>`85 SELECT r.id, r.version, a.namespace, a.slug, r.published_at, r.sha256,86 (SELECT findings FROM scan_results s WHERE s.release_id = r.id ORDER BY completed_at DESC LIMIT 1) AS findings87 FROM releases r JOIN artifacts a ON a.id = r.artifact_id WHERE r.state = 'quarantined' ORDER BY r.published_at`;88}8990/* ───────────────────────── collections ───────────────────────── */9192export interface CollectionRow {93 id: string;94 slug: string;95 title: string;96 description: string;97 category: string;98 position: number;99 item_count: number;100 updated_at: Date;101}102export const collectionDto = (c: CollectionRow) => ({ slug: c.slug, title: c.title, description: c.description, category: c.category, itemCount: c.item_count, updatedAt: c.updated_at });103104const PUBLIC_ITEM = (ctx: Ctx) => ctx.sql`a.visibility = 'public' AND a.state IN ('active','deprecated')`;105106export async function listCollections(ctx: Ctx, category?: string): Promise<CollectionRow[]> {107 return ctx.sql<CollectionRow[]>`108 SELECT c.*, (SELECT count(*)::int FROM collection_items ci JOIN artifacts a ON a.id = ci.artifact_id WHERE ci.collection_id = c.id AND ${PUBLIC_ITEM(ctx)}) AS item_count109 FROM collections c ${category ? ctx.sql`WHERE c.category = ${category}` : ctx.sql``} ORDER BY c.position, c.title`;110}111112export async function getCollection(ctx: Ctx, slug: string) {113 if (!/^[a-z0-9-]{1,64}$/.test(slug)) throw notFound("Collection");114 const [c] = (await listCollections(ctx)).filter((x) => x.slug === slug);115 if (!c) throw notFound("Collection");116 const rows = await ctx.sql<(ArtifactRow & { note: string | null; pinned_version: string | null })[]>`117 SELECT a.*, n.identity_verified, coalesce(n.display_name, n.name::text) AS owner_name, lr.version AS latest_version, lr.published_at AS latest_published_at,118 lr.security_status AS latest_security_status, 0 AS downloads_30d, ci.note, ci.pinned_version119 FROM collection_items ci JOIN artifacts a ON a.id = ci.artifact_id JOIN namespaces n ON n.id = a.namespace_id LEFT JOIN releases lr ON lr.id = a.latest_release_id120 WHERE ci.collection_id = ${c.id} AND ${PUBLIC_ITEM(ctx)} ORDER BY ci.position, a.slug`;121 return { ...collectionDto(c), items: rows.map((r) => ({ ...artifactDto(r), note: r.note, pinnedVersion: r.pinned_version })) };122}123124function requireCurator(ctx: Ctx) {125 requireScope(ctx.principal, "admin:settings");126 const p = ctx.principal;127 if (p.kind === "token" && !p.token.elevated) throw forbidden("Curation requires an elevated token.", "admin_required");128 const u = requireUser(p);129 if (!isPlatformAdmin(u)) throw forbidden("Platform administrator role required.", "admin_required");130 return u;131}132133export async function upsertCollection(ctx: Ctx, input: { slug: string; title: string; description: string; category?: string; position?: number }) {134 const user = requireCurator(ctx);135 if (!/^[a-z0-9][a-z0-9-]{1,63}$/.test(input.slug)) throw badRequest("Invalid collection slug.");136 if (!input.title?.trim() || !input.description?.trim()) throw badRequest("title and description are required.");137 await ctx.sql`138 INSERT INTO collections (slug, title, description, category, position, curated_by) VALUES (${input.slug}, ${input.title.trim().slice(0, 120)}, ${input.description.trim().slice(0, 2000)}, ${input.category ?? "research"}, ${input.position ?? 0}, ${user.id})139 ON CONFLICT (slug) DO UPDATE SET title = excluded.title, description = excluded.description, category = excluded.category, position = excluded.position, updated_at = now()`;140 await audit(ctx, { action: "collection.upsert", targetType: "collection", targetLabel: input.slug });141}142143export async function setCollectionItem(ctx: Ctx, slug: string, artifactName: string, op: "add" | "remove", note?: string) {144 requireCurator(ctx);145 const [c] = await ctx.sql<{ id: string }[]>`SELECT id FROM collections WHERE slug = ${slug}`;146 if (!c) throw notFound("Collection");147 const m = /^@?([a-z0-9-]{1,39})\/([a-z0-9-]{1,64})$/.exec(artifactName);148 if (!m) throw badRequest("artifact must look like @namespace/slug.");149 const a = await getArtifactRow(ctx, m[1]!, m[2]!);150 if (op === "add") {151 if (a.visibility !== "public") throw badRequest("Only public artifacts can be added to a collection.");152 await ctx.sql`INSERT INTO collection_items (collection_id, artifact_id, note) VALUES (${c.id}, ${a.id}, ${note?.slice(0, 300) ?? null}) ON CONFLICT (collection_id, artifact_id) DO UPDATE SET note = excluded.note`;153 } else await ctx.sql`DELETE FROM collection_items WHERE collection_id = ${c.id} AND artifact_id = ${a.id}`;154 await ctx.sql`UPDATE collections SET updated_at = now() WHERE id = ${c.id}`;155 await audit(ctx, { action: `collection.${op}`, targetType: "collection", targetId: c.id, targetLabel: `${slug} ← @${a.namespace}/${a.slug}` });156}157158/* ───────────────────────── audit trail ───────────────────────── */159160export interface AuditRow {161 id: string;162 at: Date;163 actor_label: string;164 action: string;165 target_type: string | null;166 target_id: string | null;167 target_label: string | null;168 result: string;169 ip: string | null;170 request_id: string | null;171 detail: Record<string, unknown>;172}173export const auditDto = (e: AuditRow) => ({ id: Number(e.id), at: e.at, actor: e.actor_label, action: e.action, targetType: e.target_type, targetId: e.target_id, target: e.target_label, result: e.result, ip: e.ip, requestId: e.request_id, detail: e.detail });174175export async function listAudit(ctx: Ctx, opts: { all?: boolean; before?: number; limit?: number; action?: string } = {}) {176 const user = requireUser(ctx.principal);177 requireScope(ctx.principal, "audit:read");178 const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);179 if (opts.all) requireModerator(ctx);180 const rows = await ctx.sql<AuditRow[]>`181 SELECT id, at, actor_label, action, target_type, target_id, target_label, result, ip, request_id, detail FROM audit_events182 WHERE ${opts.all ? ctx.sql`true` : ctx.sql`actor_user_id = ${user.id}`}183 ${opts.before ? ctx.sql`AND id < ${opts.before}` : ctx.sql``} ${opts.action ? ctx.sql`AND action LIKE ${opts.action.replace(/[%_\\]/g, "") + "%"}` : ctx.sql``}184 ORDER BY id DESC LIMIT ${limit}`;185 return { items: rows.map(auditDto), nextBefore: rows.length === limit ? Number(rows[rows.length - 1]!.id) : null };186}187188export async function artifactHistory(ctx: Ctx, a: ArtifactRow) {189 // Public, attributable publication history: who published or revoked what, and when. No IPs.190 const rows = await ctx.sql<{ at: Date; action: string; target_label: string | null; actor: string | null }[]>`191 SELECT e.at, e.action, e.target_label, u.display_name AS actor FROM audit_events e LEFT JOIN users u ON u.id = e.actor_user_id192 WHERE e.result = 'success' AND (193 (e.target_type = 'artifact' AND e.target_id = ${a.id} AND e.action IN ('artifact.create','artifact.deprecate','artifact.undeprecate','artifact.archive'))194 OR (e.target_type = 'release' AND e.target_id IN (SELECT id::text FROM releases WHERE artifact_id = ${a.id} AND state <> 'quarantined') AND e.action IN ('release.publish','release.revoke','release.review.approve')))195 ORDER BY e.at DESC LIMIT 100`;196 return rows.map((r) => ({ at: r.at, action: r.action, target: r.target_label, actor: r.actor }));197}198