Member writing
Let signed-in members create and edit VitNode Content Engine records from the public site with publicWrite, with server-set ownership, version checks, moderator permissions and strict field allowlists.
publicWrite lets signed-in members create records from the site and edit their own, while moderators can edit anyone's. The server sets the owner, checks every field against an allowlist and authorizes every edit; your pages only send forms.
This continues the article from Content page. So far only staff write articles, in the AdminCP. After this page, members write them too, at the same public API.
Staff content and member content
The AdminCP and public writing are two doors into the same table:
| AdminCP | Public write | |
|---|---|---|
| Who | Staff with the content type's admin permissions | Any signed-in member creates; the owner or a moderator edits |
| Session | AdminCP session | The member's normal session on the site |
| Fields | Every field | Only the fields listed in publicWrite |
| Publish, hide, delete | Yes | No. The server decides the status, and there is no public delete |
| Routes | /api/newsroom/admin/content/article | /api/newsroom/content/news |
Add an owner, versions and the write options
Edit src/content/article.ts. Add an author field, editorial and publicWrite:
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: "title" }),
body: field.richText({ required: true }),
author: field.user(),
},
publication: true,
publicApi: {
path: "news",
fields: ["id", "title", "slug", "body", "publishedAt"],
},
delivery: true,
editorial: true,
publicWrite: {
ownership: { field: "author" },
create: { fields: ["title", "body"], status: "published" },
update: { fields: ["title", "body"] },
},ownership.fieldnames the user field the server fills with the signed-in member on create. It can never be increate.fieldsorupdate.fields.createaddsPOST /and lists what a member may send.status: "published"makes a new article live at once; the default,"draft", keeps it private until staff publish it.updateaddsGET /{id}/editandPATCH /{id}, and acan_editmoderator permission.editorialadds aversionnumber to every article, so an edit form can tell when someone else saved first. It also keeps a revision history.
The moderator permission shares the newsroom:article:can_edit label you added in First content type, so there is nothing new to translate.
Migrate
Run this from the workspace root while pnpm dev is running, or after pnpm build:plugins:
bun run db:migrateThe migration adds two columns. Articles staff already wrote keep an empty author, so only moderators can edit them from the site:
ALTER TABLE "newsroom_articles" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;
ALTER TABLE "newsroom_articles" ADD COLUMN "author" integer;Check the result
Sign in on the site, copy the value of your vitnode_auth cookie from the browser's developer tools, and create an article:
curl -i -X POST http://localhost:3000/api/newsroom/content/news \
-b "vitnode_auth=<session>" \
-H "Content-Type: application/json" \
-d '{"values":{"title":"Our community garden","body":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"We planted tomatoes this week."}]}]}}}'{
"approval": null,
"id": 18,
"publishedAt": "2026-10-23T19:22:31.230Z",
"slug": "our-community-garden",
"status": "published",
"values": {
"title": "Our community garden",
"body": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "We planted tomatoes this week." }] }] }
},
"version": 2
}values holds exactly the writable fields, and version is 2 because the create and the publish each count. Now try what the server refuses:
| Request | Answer |
|---|---|
The same POST without the cookie | 401 with "code": "CONTENT_UNAUTHENTICATED" |
"title": "Hi" and "author": 1 in values | 400 with a too_small issue on title and a not_writable issue on author |
| The title of an article that exists | 409 with "code": "CONTENT_UNIQUE_CONFLICT", because its slug is taken |
The Write API lists every route, body and error. Next, give members a form with Create form.
Ownership
Every member-written record has exactly one owner, and only the server sets it. On create, the routes take the user from the session and write it into ownership.field after validating the payload. The owner never changes through the public routes, not even when a moderator edits the record. If the owner's account is deleted, the field becomes empty and only moderators can edit the record.
Who may edit
Creating needs a signed-in member. Editing is allowed for:
- The owner, while
update.ownistrue(the default) and the record is not hidden. Setupdate.own: falseto let only moderators edit. - Moderators who hold the content type's
can_editmoderator permission. Give it in AdminCP ā Staff ā Moderators: add a role or a user, choose Restricted, and turn on Edit articles under Newsroom. A root role and an Unrestricted moderator entry hold every moderator permission.
The admin can_edit permission only opens the AdminCP routes, and an AdminCP session never adds powers on the public site. Moderators edit with the same forms as members. Permissions shows how a page asks who may edit.
Security
Each layer below holds even if the one before it has a bug:
- Separate allowlists.
publicWritefields are independent ofpublicApi.fields. A field can be writable without being public, and public without being writable. Server-managed columns (id,status,publishedAt,version,hiddenAtand friends), the owner field, file, user, group, repeatable and block fields can never be listed. See writable fields. - Strict schemas. The request schemas are built from the allowlists alone. An unknown key is a
400, never silently dropped. - Authentication. An anonymous request gets
401before its body is read. - Ownership is stamped by the server, as described above.
- Authorization under a row lock. An edit reads the record with
SELECT ... FOR UPDATE, checks the owner or moderator permission, and writes in the same transaction, so the owner that was checked is the owner that is written. - Relation targets. A relation field is writable only when its target content type has a
publicApi. On every write, each referenced record must be published and visible, otherwise the request is a400with theunavailableissue code. Missing, draft and hidden targets get the same answer. - No existence leaks.
403is only for records the public can already read. A draft or a hidden record answers404, like a record that does not exist. - Rate limit and captcha.
POSTandPATCHrequests under/content/share a budget of 20 per minute per IP, on top of the write budget every mutation spends. Setcreate.captcha: trueto require a captcha token on every create. - Session and CSRF. The routes use the same session cookie, CORS and CSRF origin checks as every other VitNode API route, and they accept JSON bodies only.
Tune the write budget with rateLimiter.contentWrite. Like every rate limit, it is off when NODE_ENV=development:
export const vitNodeApiConfig = buildApiConfig({
rateLimiter: {
contentWrite: { points: 10, duration: 60 },
},
});Side effects
Public writes trigger the same side effects as AdminCP writes, so nothing new needs wiring:
- Events:
createdon create, thenpublishedwhen the record goes live, andupdatedwithchangedFieldson an edit that changed something. There are no separate public-write events. A record waiting for approval emitspublishedorupdatedwhen a moderator approves it, plus thecontent.approval.*events. - The Content Engine syncs Search after the transaction commits, so an article published on create is searchable straight away.
- On an editorial content type, each create and edit stores a revision attributed to the signed-in user, and staff can restore it from the AdminCP.
Containers
A content type organized into containers, such as discussions in forum boards, lets members pick the container when they create a record. List the field.container() in create.fields. It cannot be in update.fields, so moving a record to another container stays a staff edit. The server resolves the container inside the write's transaction and refuses a missing or foreign one with a 400 on that field.
Container policies
The server decides whether a new record starts as a draft or goes live. The payload never does. Without container policies, create.status decides. When the record's container collection has publishing policies, the container decides instead: who may create, whether new records and edits wait for a moderator, and how long the owner may edit. A new record that waits answers with status: "draft" and a pending approval object, described in Approvals. Without container policies, create.status: "draft" stores a plain draft, status: "draft" with approval: null, and no moderator is asked to review it.