feat(pages): report a public page, and let staff take one down
Summary
- Anyone looking at a public page can report it: a reason, optional details, and an optional email if they are signed out. Rate limited to 5 per hour per user or per reporter IP.
- Staff can take a page down. A takedown deletes the page's usercontent manifest, which is the Worker's authorization source, so the document, its thumbnail, and its assets all stop resolving immediately. It also stops the page unfurling, refuses every write from the creator, and sweeps that page's open reports to
upheld. - Reasons are a
textcolumn validated by zod, not a Postgres enum, so adding or renaming a moderation category is a one-line change rather than a migration.
Why / Context
Public pages are reachable by anyone with the link, and until now there was no way for a viewer to flag one or for us to act on it. This is the minimum needed to respond to a malicious or abusive page: a reporting path that does not require an account, and a takedown that actually ends serving rather than just hiding a link.
How It Works
Reporting. page.report is a publicProcedure, so a signed-out viewer can use it. The rate-limit key is the user id when signed in and an HMAC of the reporter IP otherwise, which keeps anonymous reporters limitable and groupable without storing their address. Reports are pinned to the version being served at the time, so a later publish does not move the evidence.
Takedown. takenDownAt on pages is the single flag, and every path that serves or mutates a page consults it:
| Path | Behavior once taken down |
|---|---|
writePageManifest | deletes the manifest; the Worker 404s document, thumbnail and assets |
page.publicView | returns null |
previewAccess | returns missing, so it stops unfurling even for its creator |
assertPageWritable | FORBIDDEN, so publish, update, assets and visibility changes are all refused |
page.report | NOT_FOUND, so reports stop stacking up behind a decision already made |
loadManifest in the usercontent Worker reads R2 per request with no cache and every route fails closed on a missing manifest, so the takedown takes effect on the next request rather than after a TTL.
Ordering. takedown runs the page flag and the report sweep in one transaction, then deletes the manifest and rolls the transaction back if that fails. The delete cannot move inside the transaction: writePageManifest reads the page on its own connection, would still see a live page, and would write a fresh manifest instead of deleting one. There is a comment at the call site saying so.
Design Decisions
reasonistext, not a Postgres enum.ALTER TYPE ... ADD VALUEis cheap to run, but it still couples adding a moderation category to a migration and a deploy, and enum values can never be renamed or removed once shipped. Moderation categories get renamed.pageReportReasonValuesis now the single source of truth: zod validates against it at the tRPC boundary, and the report dialog's labels are aRecordkeyed byPageReportReason, so a category added to the list and missed in the dialog is a compile error naming the missing one. Verified by temporarily adding a 9th reason:error TS2741: Property 'self_harm' is missing. The dialog's import is type-only, so nothing is added to the client bundle.page_report_statusstays an enum.open/upheld/dismissedis a real state machine that will not churn, and the database is the right place to enforce it.- Reporter IPs are stored hashed, never raw. Enough to rate limit and to group reports from one source, without holding an address.
Manual QA Checklist
Reporting
- Report button appears on a public page for both signed-in and signed-out viewers
- Submitting with only a reason succeeds; the dialog switches to the confirmation state
- The email field appears only when signed out, and a malformed address is rejected
- Details longer than 4000 characters are rejected
- The 6th report within an hour returns "Too many reports. Try again later."
Takedown
- After
page.takedown, the public page URL no longer renders the content - The document, thumbnail and asset URLs all 404 rather than serving from cache
- A Slack/Discord unfurl of the page stops previewing, including for its creator
- The creator cannot publish, update, change visibility, or upload assets to the page
- That page's open reports are now
upheldwithreviewedAtandreviewedByUserIdset -
page.restorebrings the page back and clears the takedown note
Authorization
-
listReports/takedown/restore/reviewReport/reportCountsreturn FORBIDDEN for a non-staff account - Reporting a slug that does not exist, is not public, or is already taken down returns NOT_FOUND
Migration
-
0129_page_reports_and_takedown.sqlapplies cleanly on a fresh DB and on a branch with existing pages - Existing pages are unaffected:
taken_down_atis NULL and they serve normally
Testing
bun run typecheck --force(all 42 packages)bun run lintbun run check:i18n(16 locales, 0 missing of 5646)bunx sherifbun run test:@superset/trpc392 pass / 0 fail,@superset/web53 pass / 0 fail
Pre-existing failures unrelated to this branch, left alone: 41 jest.advanceTimersByTime is not a function errors in @superset/host-service git-watcher and @superset/desktop terminal-ws-transport under bun 1.3.2, plus a @superset/workspace-fs pathTypes timing test that flakes under full-suite load and passes 3/3 in isolation.
Risks / Rollout
Migration lock. The last statement, ALTER TABLE pages ADD CONSTRAINT pages_taken_down_by_user_id_users_id_fk, validates the new FK against every row in pages. Because drizzle runs all pending migrations in one transaction, the ACCESS EXCLUSIVE lock taken on pages by the earlier ADD COLUMN statements is held through that scan, so reads and writes of pages block for its duration. It also holds SHARE ROW EXCLUSIVE on auth.users from the page_reports FK onward, which blocks writes to auth.users (reads are unaffected, ROW SHARE does not conflict). pages is under 1M rows, so the scan is sub-second and this is safe to run under load. If pages grows past a few million, split the constraint into ADD CONSTRAINT ... NOT VALID plus a later VALIDATE CONSTRAINT.
Rollback. The feature is additive. Reverting the code leaves taken_down_at set on any page taken down in the interim, which would be ignored by the reverted code, so any such page would start serving again; re-publishing it would also restore its manifest. To stop impact without a revert, call page.restore.
Migration numbering. Originally generated as 0127; renumbered to 0128 after 0127_environments_region landed on main, then to 0129 after 0128_drop_environments_name_unique landed. Each renumber was regenerated with drizzle-kit against the merged schema; the SQL is unchanged. The PR's Neon preview branch predates the renumber and must be deleted so the preview migration check does a real run.
Known Limitations
- No admin UI yet.
listReports,reviewReport,takedown,restoreandreportCountsexist and are staff-gated but have no caller, so a takedown currently means invoking the tRPC procedure directly. The UI is a deliberate follow-up. - No dedup on reports: the same reporter can file repeatedly on the same page across rate-limit windows.
reporterIpreadsx-forwarded-forthe same way four other call sites in the repo already do. That header is only trustworthy because the edge overwrites it; the rate limit inherits that assumption.
Follow-ups
- Admin reports queue in
apps/admin, following the existing(dashboard)/growthtile pattern. - Notify the page owner when their page is taken down. Right now
takenDownNoteis recorded but never surfaced.
Summary by cubic
Anyone viewing a public page can now report it, and staff can take a page down so it stops being served on the next request.
Reporting
- Any signed-out viewer can report a page with a reason, optional details, and an optional email; no account needed.
- Rate limited to 5 reports per hour per user or per reporter IP; reporter IPs are stored hashed, never raw.
- Report reasons are a
textcolumn validated by zod, so adding or renaming a moderation category is a one-line change, not a migration. - A signed-in reporter's email is ignored — the account is already the contact — and server errors surface in the viewer's locale.
Takedown
- A staff takedown deletes the page's usercontent manifest, which 404s the document, thumbnail, and assets on the next request and stops it unfurling, even for its creator.
- The creator can no longer publish, update, or change visibility once taken down; those writes also require
takenDownAt IS NULL, closing the race against a concurrent takedown. Open reports on the page are swept toupheld. restorebrings the page back; the staff procedures have no admin UI yet, so a takedown currently means invoking the tRPC procedure directly.- Migration 0129 is additive — a new
page_reportstable and three nullable columns onpages, leaving existing pages unaffected.
Summary by CodeRabbit
-
New Features
- Added a way to report public pages, with selectable reasons and optional details; signed-out visitors can also provide an email address.
- Added notices explaining that public page content is unverified and identifying its publisher.
- Added tools for administrators to review reports and take down or restore pages.
-
Bug Fixes
- Taken-down pages are no longer available in public views or editable, and their shared manifests are removed.
-
Documentation
- Added translations for reporting flows and page notices in supported languages.