diff --git a/scripts/moderate.ts b/scripts/moderate.ts index da02db6..806438c 100644 --- a/scripts/moderate.ts +++ b/scripts/moderate.ts @@ -1,19 +1,7 @@ #!/usr/bin/env bun -// Appview moderation CLI. Hides abusive content from *our* reads without ever -// touching a PDS — the underlying wiki.lichen.* records stay live and resolvable -// by other clients. "Operator" = whoever can SSH to the box and run this against -// the DB at DB_PATH (prod: /var/lib/lichen/lichen.db). -// -// bun run moderate hide @alice.test/badwiki [--reason="spam"] [--by=julien] -// bun run moderate hide @alice.test/badwiki --user [--reason] [--by] # all wikis + the DID -// bun run moderate unhide @alice.test/badwiki # un-hide the wiki -// bun run moderate unhide @alice.test/badwiki --user # lift the DID ban -// bun run moderate list -// -// Read hide hides a single wiki (keyed by at_uri). --user bans the owner DID: -// every wiki it owns (now and future) is hidden, and its future writes are -// dropped (Phase 5). Hiding never deletes rows; unhide/unban fully reverses it. +// Appview moderation CLI — hides content from our reads without touching any PDS. +// Runs against the DB at DB_PATH (prod: /var/lib/lichen/lichen.db). See USAGE below. import { resolveHandleToDid } from "../src/lib/profile.ts"; import { @@ -113,7 +101,6 @@ async function run(args: ParsedArgs): Promise { const { owner, slug } = splitTarget(args.target); const did = await resolveDid(owner); - // --user: act on the owner DID. Hides every wiki it owns (now and future). if (args.user) { if (args.command === "hide") { banDid(did, args.reason, args.by); @@ -127,13 +114,12 @@ async function run(args: ParsedArgs): Promise { return; } - // Single wiki: keyed by at_uri. Prefer the DB row; else construct it so a - // not-yet-seen wiki can be pre-emptively hidden (rkey is the slug). if (!slug) { throw new Error( `${args.command} needs a wiki slug (got "${args.target}"). Use --user to act on the whole account.`, ); } + // Construct the at_uri when absent so a not-yet-seen wiki can be pre-hidden (rkey = slug). const atUri = getWiki(did, slug)?.at_uri ?? `at://${did}/wiki.lichen.wiki/${slug}`; diff --git a/src/atproto/session.ts b/src/atproto/session.ts index 23c40d1..50e85e5 100644 --- a/src/atproto/session.ts +++ b/src/atproto/session.ts @@ -2,7 +2,7 @@ import { Client } from "@atcute/client"; import type { Did } from "@atcute/lexicons/syntax"; import type { OAuthClient, OAuthSession } from "@atcute/oauth-node-client"; import { resolveProfile } from "../lib/profile.ts"; -import { getAppSessionDid } from "../server/db/queries/index.ts"; +import { getAppSessionDid, isDidBanned } from "../server/db/queries/index.ts"; import { createOAuthClient } from "./client.ts"; import { getAtprotoEnv, getDevAccounts } from "./env.ts"; @@ -93,10 +93,13 @@ export async function getSessionFromCookie( ): Promise { const cookie = cookieHeader ?? undefined; const client = await getClient(); - if (client) { - return getSession(client, cookie); - } - return getDevSession(cookie); + const session = client + ? await getSession(client, cookie) + : getDevSession(cookie); + // Banned DID (`hide --user`): resolve to no session, blocking every authenticated + // action — including the optimistic app write the firehose drop can't catch. + if (session && isDidBanned(session.did)) return null; + return session; } export async function getSessionFromRequest( diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index 954a10a..e813894 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -13,6 +13,7 @@ import { getMemberRole, getNoteByAtUri, getWikiByAtUri, + isDidBanned, setWikiTheme, upsertBookmark, upsertMembership, @@ -207,6 +208,9 @@ export interface FirehoseCommit { } export function handleCommitEvent(evt: FirehoseCommit): void { + // Banned DID (`hide --user`): drop its commits. Local filter, no Jetstream coupling. + if (isDidBanned(evt.did)) return; + const atUri = `at://${evt.did}/${evt.collection}/${evt.rkey}`; if (evt.operation === "delete") { diff --git a/src/lib/access-resolve.ts b/src/lib/access-resolve.ts index 65accee..b127961 100644 --- a/src/lib/access-resolve.ts +++ b/src/lib/access-resolve.ts @@ -111,9 +111,7 @@ export async function resolveRequestContext( }; } - // Full takedown: every direct route funnels through here, so one check blocks - // wiki home, notes, edit, history, export, settings, members and sidebar — - // including for the owner. Appview-local only; the PDS records are untouched. + // Full takedown — 410s every direct route, owner included. if (isWikiHidden(wiki)) { throw new WikiRemovedError(undefined, { i18nKey: "wikiRemoved" }); } diff --git a/src/server/db/queries/moderation.ts b/src/server/db/queries/moderation.ts index e3d9daa..7ac49bb 100644 --- a/src/server/db/queries/moderation.ts +++ b/src/server/db/queries/moderation.ts @@ -9,7 +9,7 @@ export interface ModerationRow { created_at: string; } -// Excludes wikis hidden directly or via a DID ban. Splice into a WHERE on alias `w`. +// Splice into a WHERE clause on alias `w`. export const WIKI_NOT_MODERATED = ` w.at_uri NOT IN (SELECT subject FROM moderation_actions WHERE subject_type = 'wiki') AND w.did NOT IN (SELECT subject FROM moderation_actions WHERE subject_type = 'did')`; @@ -64,7 +64,6 @@ export function unbanDid(did: string): void { remove("did", did); } -// Hidden if the wiki itself is taken down, or its owner DID is banned. export function isWikiHidden(wiki: Pick): boolean { const db = getDb(); const row = db diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 471ea13..1aab995 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -177,10 +177,8 @@ export function initSchema(db: Database): void { ) `); - // Appview-only moderation. A row hides content from our reads without touching - // any PDS — the underlying records stay live. Kept off the wikis row so it - // survives upsertWiki (fired on every wiki event) and the delete cascade, and - // so a DID can be banned before it owns any wiki. + // Separate table (not a wikis column) so takedowns survive upsertWiki and the + // delete cascade, and a DID can be banned before it owns any wiki. db.run(` CREATE TABLE IF NOT EXISTS moderation_actions ( subject_type TEXT NOT NULL CHECK (subject_type IN ('wiki', 'did')), diff --git a/src/server/routes/og.ts b/src/server/routes/og.ts index e793c50..f89d19c 100644 --- a/src/server/routes/og.ts +++ b/src/server/routes/og.ts @@ -29,7 +29,6 @@ export const ogRoutes = new Elysia({ prefix: "/og" }) if (wiki.visibility !== "public") { return new Response("Not found", { status: 404 }); } - // Taken-down wikis: no card, same as not found. if (isWikiHidden(wiki)) { return new Response("Not found", { status: 404 }); } diff --git a/src/views/removed.ts b/src/views/removed.ts index 3aafdc0..9f67f90 100644 --- a/src/views/removed.ts +++ b/src/views/removed.ts @@ -3,8 +3,7 @@ import { t } from "../lib/i18n/index.ts"; import { type LayoutOptions, layout } from "./layout.ts"; import { primaryButtonClass, THEME } from "./theme/index.ts"; -// 410 tombstone for content taken down by the appview operator. The reason is -// deliberately not shown — it could itself be abusive or defamatory. +// The takedown reason is intentionally not shown — it could itself be abusive. export function removedPage(options: LayoutOptions = {}): string { const locale = options.locale ?? "en"; const msg = t(locale); diff --git a/tests/integration/moderation-ban.test.ts b/tests/integration/moderation-ban.test.ts new file mode 100644 index 0000000..18eb587 --- /dev/null +++ b/tests/integration/moderation-ban.test.ts @@ -0,0 +1,46 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { getSessionFromCookie } from "../../src/atproto/session.ts"; +import { + banDid, + getWiki, + unbanDid, +} from "../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; +import { BOB, emitWiki } from "./helpers.ts"; + +const GHOST_DID = "did:plc:bannedghost0000000000000"; +const GHOST_SLUG = "ghost-wiki"; + +afterAll(() => { + unbanDid(GHOST_DID); + unbanDid(BOB.did); + cleanupWikiAndDependents(GHOST_SLUG); +}); + +describe("firehose ingest blocks a banned DID", () => { + test("commits are dropped while banned, ingested after unban", () => { + banDid(GHOST_DID, "abuse", "test"); + emitWiki(GHOST_DID, "Ghost Wiki", "public", GHOST_SLUG); + expect(getWiki(GHOST_DID, GHOST_SLUG)).toBeNull(); + + unbanDid(GHOST_DID); + emitWiki(GHOST_DID, "Ghost Wiki", "public", GHOST_SLUG); + expect(getWiki(GHOST_DID, GHOST_SLUG)).not.toBeNull(); + }); +}); + +// Asserted at the getSessionFromCookie chokepoint, not over HTTP: +// tests/lib/access-context mock.module's session.ts process-globally, which would +// shadow the route-level path. +describe("session resolution blocks a banned DID", () => { + test("a banned account resolves to no session, restored on unban", async () => { + const cookie = `did=${encodeURIComponent(BOB.did)}`; + expect(await getSessionFromCookie(cookie)).not.toBeNull(); + + banDid(BOB.did, "abuse", "test"); + expect(await getSessionFromCookie(cookie)).toBeNull(); + + unbanDid(BOB.did); + expect(await getSessionFromCookie(cookie)).not.toBeNull(); + }); +}); diff --git a/tests/integration/moderation.test.ts b/tests/integration/moderation.test.ts index a70a609..f92731e 100644 --- a/tests/integration/moderation.test.ts +++ b/tests/integration/moderation.test.ts @@ -69,7 +69,6 @@ describe("wiki takedown via hideWiki", () => { describe("DID ban via banDid", () => { test("bans hide the owner's wiki, unban restores it", async () => { - // Sanity: the wiki row still exists; only reads are filtered. expect(getWiki(ALICE.did, WIKI_SLUG)).not.toBeNull(); banDid(ALICE.did, "operator", "test"); diff --git a/tests/server/db/queries/moderation.test.ts b/tests/server/db/queries/moderation.test.ts index dbb410c..1436a76 100644 --- a/tests/server/db/queries/moderation.test.ts +++ b/tests/server/db/queries/moderation.test.ts @@ -68,7 +68,6 @@ describe("isWikiHidden truth table", () => { test("hidden via owner DID ban", () => { banDid(DID, null, null); expect(isWikiHidden({ at_uri: AT_URI, did: DID })).toBe(true); - // A different owner is unaffected by this DID ban. expect(isWikiHidden({ at_uri: OTHER_AT_URI, did: OTHER_DID })).toBe(false); unbanDid(DID); }); @@ -116,7 +115,6 @@ describe("list query filtering", () => { hideWiki(FILTER_AT_URI, null, null); const after = listFor(); expect(after.wikis.some((w) => w.slug === FILTER_SLUG)).toBe(false); - // The COUNT(*) total uses the same filter, so it drops in lock-step. expect(after.total).toBe(before.total - 1); unhideWiki(FILTER_AT_URI);