Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/content/docs/content-engine/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ For a proposal, `values` is still the live record and `approval.values` holds wh

To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member. Pass `pluginId` to keep only your plugin's submissions - the filter runs before the page is cut, so every page is full and `total` counts only yours:

```ts title="plugins/forum/src/pages/my-submissions-data.ts"
```ts
import { fetcher } from "@vitnode/core/tanstack/fetcher";

export const fetchMySubmissions = async (page: number) => {
Expand All @@ -83,7 +83,7 @@ export const fetchMySubmissions = async (page: number) => {
};
```

Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`.
Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. The Forum plugin's `pending-page.tsx` renders this list at `/forum/me/pending`.

## What a decision does

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/public-write-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_

### Rate limit

`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.
`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. The budget counts per signed-in user when the request carries a session cookie, and per IP otherwise (`rateLimiter.perUser`, on by default). It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.

### Codes

Expand Down
11 changes: 10 additions & 1 deletion apps/web/content/docs/content-engine/public-write-frontend.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,15 @@ Restart `pnpm dev` and sign in with a member account:

{/* Image prompt: The /discussions/new page of the example plugin: "Start a discussion" heading, a Title input and a Message rich text editor, and a "Post discussion" button. Light theme, 1440x900. */}

## Approvals and edit windows

`example.discussion` publishes straight away. When a content type is filed in a container with [publishing policies](/docs/content-engine/container-policies), the same routes can answer differently:

- A create or edit that needs a moderator still answers `201` or `200`, with `approval.status: "pending"`. Tell the author it is waiting instead of saying it is live. `GET /{id}/edit` returns the pending proposal in `approval.values`. See [Approvals](/docs/content-engine/approvals#what-the-author-sees).
- A create in a container with `create: "moderators"` answers `403` with the `moderators_only` issue. An owner edit after `authorEdit` stopped allowing it answers `403` with `edit_window_closed`.

The Forum plugin handles all of these in `topic-edit-page.tsx` and `comment-composer.tsx`.

## Ask for a captcha

With `create.captcha: true`, every create needs a captcha token, as long as a [captcha provider](/docs/dev/captcha) is configured. Pass the deployment's captcha config to `AutoForm` and forward the token it hands to `onSubmit`:
Expand Down Expand Up @@ -680,7 +689,7 @@ The routes do not care how the form is built. Any form library, or none, works a
2. On `400`, `401`, `403` or `404`, read the body with `parseContentPublicWriteRejection` from `@vitnode/core/content`. It takes the parsed JSON and returns the rejection, or `null` for any other shape.
3. On `409`, check `code` for `CONTENT_VERSION_CONFLICT` or `CONTENT_UNIQUE_CONFLICT`.

This plain React form turns a textarea into paragraphs and puts each issue under its field:
For example, this plain React form, which you write yourself, turns a textarea into paragraphs and puts each issue under its field:

```tsx title="plugins/example/src/pages/quick-discussion-form.tsx"
import {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ visibility: true,

## `reports`

`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type).
`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Render the public button with `ReportButton` and `contentTarget(manifest.id, definition, record.id)`. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options and components: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type).

```ts title="plugins/blog/src/content/post.ts"
publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] },
Expand Down
23 changes: 16 additions & 7 deletions apps/web/content/docs/content-engine/threads.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,21 +134,28 @@ import { fetcher } from "@vitnode/core/tanstack/fetcher";

import { manifest } from "@/manifest.gen";

export const fetchTopicThread = async (topicId: number, after?: string) => {
export const fetchThreadPage = async ({
after,
topicId,
}: {
after?: string;
topicId: number;
}) => {
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/comments",
path: "/thread",
args: { query: { root: String(topicId), first: 20, after } },
args: { query: { root: String(topicId), first: 20, ...(after ? { after } : {}) } },
});
if (!res.ok) throw new Error("Could not load the comments.");
if (res.status === 404) return null;
if (res.status !== 200) throw new Error("Loading the comments failed.");

return await res.json();
};
```

`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page.
`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page. A `404` means the root record is missing or not public. The Forum plugin's `thread-data.ts` also validates the response with Zod, and `thread.tsx` renders it as a nested list.

</Step>

Expand All @@ -174,7 +181,7 @@ export const fetchTopicThread = async (topicId: number, after?: string) => {
"id": 17,
"slug": "k3v9q2m7xa",
"body": { "type": "doc", "content": [] },
"topic": 42,
"topic": { "id": 42 },
"parent": null,
"publishedAt": "2026-10-13T09:20:00.000Z",
"createdAt": "2026-10-13T09:20:00.000Z",
Expand All @@ -189,7 +196,7 @@ export const fetchTopicThread = async (topicId: number, after?: string) => {
}
},
{ "depth": 2, "id": 18, "parentId": 17, "removed": true, "record": null },
{ "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": 18 } }
{ "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": { "id": 18 } } }
],
"pageInfo": { "endCursor": "WyIyMDI2LTEwLTEzVDA5OjIwOjAwLjAwMFoiLDE3XQ", "hasNextPage": false, "totalCount": 1 },
"replyCount": 2,
Expand All @@ -202,7 +209,9 @@ export const fetchTopicThread = async (topicId: number, after?: string) => {
- **Limits.** One page carries at most 500 nodes and stops at `maxDepth`. `truncated: true` means some nested replies did not fit.
- **The root must be public.** A missing, draft or hidden topic answers `404`.

`record` has the same shape as a record from the public list, including `people` summaries. A person is `null` when the account was deleted.
`record` has the same shape as a record from the public list, including `people` summaries. A to-one relation such as `topic` or `parent` comes back as `{ id }`, not a bare id. A person is `null` when the account was deleted.

Readers can report a reply like any other record: turn on [`reports`](/docs/dev/moderation/enable-reports#report-items-in-a-list) for the reply type and render a `ReportButton` per reply.

## Placement rules

Expand Down
54 changes: 49 additions & 5 deletions apps/web/content/docs/dev/moderation/enable-reports.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,29 +75,73 @@ Enabling reports grants nothing. An administrator still has to give these permis

### Place the Report button

A plugin's own pages place the button themselves. Pass the record's stable id and the language the reader was served:
A plugin's own pages place the button themselves. Pass the record as a `target` built with `contentTarget`, plus the language the reader was served:

```tsx title="plugins/blog/src/pages/post-page.tsx"
import { contentTarget } from "@vitnode/core/content";
import { ReportButton } from "@vitnode/core/tanstack/moderation";

import { blogPostContentType } from "@/content/post";
import { manifest } from "@/manifest.gen";

{!metadata.hidden && metadata.itemId !== null ? (
<ReportButton
locale={metadata.locale}
pluginId={manifest.id}
requestedLocale={metadata.requestedLocale}
targetId={metadata.itemId}
targetType={blogPostContentType.id}
target={contentTarget(manifest.id, blogPostContentType, metadata.itemId)}
typeLabel={t("report_type")}
/>
) : null}
```

`contentTarget(pluginId, definition, id)` returns `{ pluginId, type, id }`: your plugin id, the content type id (`"blog.post"`) and the record id. Building it from the definition keeps the type id in sync with what the API registered. `typeLabel` is the item's name, already translated, for the **Report article** title, so add it to your plugin's locale file:

```json title="plugins/blog/src/locales/en.json"
{
"@vitnode/blog": {
"post": {
"report_type": "article"
}
}
}
```

The blog reads `itemId`, `locale` and `requestedLocale` from the [delivery resolution](/docs/content-engine/public-pages), so the button reports the article in the language the reader actually saw, even after a fallback. The form loads only when the dialog opens. A signed-out reader gets a **Sign in** link instead of the form.

</Step>

</Steps>

## Report items in a list

Comments, replies and other list items work the same way: the record id comes from the item you are rendering. The forum puts a button in each comment's action row, for everyone except the comment's author:

```tsx title="plugins/forum/src/pages/thread.tsx"
import { contentTarget } from "@vitnode/core/content";
import { ReportButton } from "@vitnode/core/tanstack/moderation";

import { commentContentType } from "@/content/comment";
import { manifest } from "@/manifest.gen";

const authorId = `${commentAnchorId(comment.slug)}-author`;

<span id={authorId}>
<PersonName person={comment.author} />
</span>

{isOwner ? null : (
<ReportButton
describedBy={authorId}
target={contentTarget(manifest.id, commentContentType, comment.id)}
typeLabel={t("report_type")}
/>
)}
```

`comment.id` comes from the [thread](/docs/content-engine/threads) response. Comments are not localized, so the button needs no `locale`. `describedBy` points at the author's name, so a screen reader announces "Report comment, Jane" instead of 30 identical "Report comment" buttons.

Hiding the button from the author is a choice the forum makes, not a server rule. Every button shares one request for its messages and loads the form only when its dialog opens. To open the dialog from your own trigger, such as a menu item, use [`ReportDialog`](/docs/dev/moderation/reference#reportdialog).

## Check the result

Rebuild with `pnpm build:plugins`, restart `pnpm dev`, sign in as a regular user and open a published article. Click **Report**, pick a reason and send it:
Expand All @@ -111,7 +155,7 @@ A toast says **Report sent**. Try sending it again and the toast tells you, poli

## What the server checks

- The reporter must be signed in. A guest gets `401`.
- The reporter must be signed in. A guest gets `401`, and a reader whose session ended mid-dialog sees a "sign in again" message.
- The target must be readable by the reporter through the public API. Anything else is `404`, the same answer as a typo.
- **Other** needs at least 10 characters of details. Details are at most 2000 characters.
- One reporter can hold one open report per target. Other reporters get their own reports.
Expand Down
48 changes: 47 additions & 1 deletion apps/web/content/docs/dev/moderation/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Exact names, types and behavior of the moderation APIs. For step-by-step guides,

`reports: true` uses both defaults. It throws without `publicApi`, for an unknown option, for `reports.enabled`, and for a field that does not exist or cannot be snapshotted.

The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id.
The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id. A content type that reaches its container through a relation, such as a forum comment through its topic, has no such column, so its report permissions stay unscoped.

## Generated moderator permissions

Expand Down Expand Up @@ -83,6 +83,52 @@ Content Engine types register their target automatically. Anything else register

`run` executes inside the report's transaction and returns `{ changed, effects }`. `effects` runs after the commit. When `run` throws, the transaction rolls back and no timeline entry is written.

## Report UI

Public pages report a target with the components from `@vitnode/core/tanstack/moderation`. Both load their own messages (`REPORT_NAMESPACES`) and lazy-load the form, and both show a **Sign in** link instead of the form to guests. For a walkthrough, see [Enable reports](/docs/dev/moderation/enable-reports).

### `contentTarget`

`contentTarget(pluginId, definition, id)` from `@vitnode/core/content` returns the `ContentTarget` of one record: `{ pluginId, type, id }`, where `type` is `definition.id` and `id` is the record id as a string. A custom report target uses the same shape with its own `type`.

### `ReportButton`

A ghost button labelled **Report** that opens the dialog. Its accessible name is "Report `typeLabel`".

| Name | Type | Required | Default | Description |
| ----------------- | ---------------- | -------- | ------- | --------------------------------------------------------------------------- |
| `target` | `ContentTarget` | Yes | | The reported record |
| `typeLabel` | `string` | Yes | | What the item is called, already translated, for "Report `typeLabel`" |
| `describedBy` | `string` | No | | Id of the element naming the item, read after the label so list buttons differ |
| `locale` | `string \| null` | No | `null` | The locale the reader was served, for moderators |
| `requestedLocale` | `string \| null` | No | `null` | The locale the reader asked for, when the content fell back to another |
| `className` | `string` | No | | Classes for the button |

### `ReportDialog`

The same dialog, controlled by you. Use it for your own trigger, such as a menu item, or to share one dialog across a long list. It takes `target`, `typeLabel`, `locale` and `requestedLocale` like `ReportButton`, plus `open: boolean` and `onOpenChange(open: boolean)`.

```tsx
import type { ContentTarget } from "@vitnode/core/content";

import { ReportDialog } from "@vitnode/core/tanstack/moderation";

const [reporting, setReporting] = useState<ContentTarget | null>(null);

{reporting ? (
<ReportDialog
onOpenChange={open => {
if (!open) setReporting(null);
}}
open
target={reporting}
typeLabel={t("report_type")}
/>
) : null}
```

The dialog shows a toast with a description after every outcome: sent, already reported (`409 duplicate`), no longer available (`404`), too many reports (`429`), session ended (`401`), invalid details (`400`) and anything else.

## Report lifecycle

| Status | Reached by |
Expand Down
59 changes: 58 additions & 1 deletion packages/vitnode/src/api/modules/moderation/reports.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Context } from "hono";

import { OpenAPIHono } from "@hono/zod-openapi";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
import type { ModerationCaptureResult } from "@/api/moderation";
Expand All @@ -14,6 +14,7 @@ import {
} from "@/database/moderation";
import { core_roles } from "@/database/roles";
import { core_users } from "@/database/users";
import { MODERATION_LIMITS } from "@/lib/moderation/const";
import { createTestCache } from "@/tests/cache";
import { createMemoryDb } from "@/tests/memory-db";
import {
Expand Down Expand Up @@ -336,6 +337,62 @@ describe("filing a report", () => {
expect(h.memory.rows(core_moderation_report_events)).toEqual([]);
});

afterEach(() => {
vi.useRealTimers();
});

it("asks a guest to sign in", async () => {
const h = await harness();

const res = await h.report(0, "1");

expect(res.status).toBe(401);
expect(h.memory.rows(core_moderation_reports)).toEqual([]);
});

it.each([
[MODERATION_LIMITS.otherDescriptionMinLength - 1, 400],
[MODERATION_LIMITS.otherDescriptionMinLength, 201],
])(
'needs a description of the problem for "other" (%i characters: %i)',
async (length, status) => {
const h = await harness();

const res = await h.report(REPORTER, "1", {
description: "x".repeat(length),
reason: "other",
});

expect(res.status).toBe(status);
},
);

it("limits reports per hour and lets the reporter go on once it passes", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-18T12:00:00.000Z"));
const h = await harness();
await h.memory.db.insert(core_moderation_reports).values(
Array.from({ length: MODERATION_LIMITS.reportsPerUserPerHour }, () => ({
openKey: null,
pluginId: FORUM,
reason: "spam" as const,
reporterId: REPORTER,
snapshot: {},
targetId: "2",
targetType: TYPE,
})),
);

const limited = await h.report(REPORTER, "1");
expect(limited.status).toBe(429);
expect(limited.headers.get("retry-after")).toBe("3600");
expect(await limited.json()).toEqual({ error: "rate_limited" });
expect((await h.report(SECOND_REPORTER, "1")).status).toBe(201);

vi.setSystemTime(new Date("2026-10-18T13:00:01.000Z"));
expect((await h.report(REPORTER, "1")).status).toBe(201);
});

it("answers 404 when the reporter cannot read the target", async () => {
const h = await harness();

Expand Down
2 changes: 2 additions & 0 deletions packages/vitnode/src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,8 @@ export {
} from "./sitemap";
export type { ContentSitemapEntry, ContentSitemapIndexEntry } from "./sitemap";
export { slugify } from "./slug";
export { contentTarget } from "./target";
export type { ContentTarget } from "./target";
export type {
AnyContentTypeDefinition,
ContentAdminActionConfig,
Expand Down
Loading
Loading