USER_SAFETY.md step 2 (core), with S11-S14 decided while building it. - Reports: POST /player/reports takes any target type, core's forum types or a module's registered ones. One resolver (model/safety/targets.js) answers "what is this item, for this reader" and 404s an item the reader cannot see. Every report stores content_reports.snapshot; the queue shows a module item from it. - Hide for everyone: POST /admin/moderation/reports/:id/hide hides the item through its own moderation (forum hide as staff, or the module's hide) and actions the report with actioned_with = 'hide'. - Blocks (user_blocks) name an item, never a person, and land on the author's account first (S12); an unlinked game identity is blocked as itself and becomes an account block once linked (S14). Staff cannot be blocked; a block on someone later promoted stops applying (S8). - Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden. - Forum reads filter blocked authors and hidden items on the server and carry canBlock per thread and post. - MODULE_API 1.21.0: api.registerReportTargets, api.registerGameIdentities, ctx.safety.forViewer and ctx.safety.staffAmong, so a module filters its own per-viewer feeds on the server (S11) and marks lines canBlock (S13). - Web: Hide for me / Block author in the forum menus, a Blocked section on both account pages, Hide for everyone in the Reports queue. - Tests, OpenAPI, route/guard/engagement manifests regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
488 lines
21 KiB
JavaScript
488 lines
21 KiB
JavaScript
// ── The forum: access + announcements (5a), discussion + moderation (5b) ───
|
|
//
|
|
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a shipped the whole access
|
|
// model and a single announcements stream per Team; 5b (phase 5) opens discussion
|
|
// threads, replies, editing and post-level moderation. The schema for all of it
|
|
// landed together, so this phase added no ALTER — every column it needed
|
|
// (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the
|
|
// ledger's `target_type='post'`) was already there waiting.
|
|
//
|
|
// **Every function here takes an already-resolved access decision.** Nothing in
|
|
// this file reads `team_members` or `team_forum_grants`; the caller asks
|
|
// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never
|
|
// by checking membership directly, which is how paths 1 and 3 would drift back
|
|
// together", made structural.
|
|
//
|
|
// **The read path is where the image policy is applied**, once, in `renderPost`.
|
|
// Not in the controller and never in the client: the client is TOLD the mode so it
|
|
// can draw the right composer, and is never the thing that decides whether an
|
|
// image appears (§5.5.6).
|
|
|
|
const forumDb = require('./teamForum.db')
|
|
const forumSettings = require('./teamForumSettings.model')
|
|
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
|
const { displayAuthor, STAFF_ROLES } = require('../../utils/displayAuthor')
|
|
const safetyDb = require('../safety/userSafety.db')
|
|
|
|
// Announcements are leader-authored and take no replies; discussion threads are
|
|
// member-authored and do. Both have been in the enum since 5a — what phase 5
|
|
// changed is that both are now CREATABLE, and by different people.
|
|
//
|
|
// **The authority split lives in the controller, not here.** This list says what
|
|
// kinds of thread exist; who may make one is a question about the caller, which
|
|
// this file deliberately never asks (see the header on access decisions).
|
|
const CREATABLE_TYPES = ['announcement', 'discussion']
|
|
|
|
// Kept as an export because it names a real fact — the one type 5a could create —
|
|
// and because removing a name from a module's surface to save a line is how a
|
|
// consumer outside this repo breaks. It is not used to decide anything.
|
|
const CREATABLE_TYPES_5A = ['announcement']
|
|
|
|
// Which thread types accept replies. An announcement's `locked` stays false even
|
|
// though nothing may reply to it: replies are refused because the TYPE takes none,
|
|
// not because the thread was closed, and conflating the two would make "unlock"
|
|
// look like it would open replies on an announcement.
|
|
const REPLYABLE_TYPES = ['discussion']
|
|
|
|
/**
|
|
* Moderation actions, and what each one does to the row.
|
|
*
|
|
* A table rather than a switch because the ledger and the effect have to stay in
|
|
* step: every entry here writes one row of `team_forum_moderation` naming the
|
|
* authority that was exercised, and an action with an effect but no ledger entry
|
|
* would be a moderation nobody can audit.
|
|
*/
|
|
const THREAD_ACTIONS = {
|
|
pin: { pinned: true },
|
|
unpin: { pinned: false },
|
|
lock: { locked: true },
|
|
unlock: { locked: false },
|
|
hide: { status: 'hidden' },
|
|
unhide: { status: 'visible' },
|
|
delete: { status: 'deleted' },
|
|
restore: { status: 'visible' },
|
|
}
|
|
|
|
// Post-level moderation. A strict subset of THREAD_ACTIONS: `pin` and `lock`
|
|
// describe a thread's place in a list and its openness to replies, neither of
|
|
// which a post has. Naming them here as "not applicable" rather than as "unknown"
|
|
// is what lets `moderatePost` tell a caller which mistake they made.
|
|
const POST_ACTIONS = {
|
|
hide: { status: 'hidden' },
|
|
unhide: { status: 'visible' },
|
|
delete: { status: 'deleted' },
|
|
restore: { status: 'visible' },
|
|
}
|
|
|
|
/**
|
|
* One thread's list shape, for one viewer.
|
|
*
|
|
* `author` follows displayAuthor's rule: a deleted account's snapshot reads
|
|
* "[deleted player]" to a member and its stored name to staff, and
|
|
* `authorDeleted` says which case it is so the staff view can mark it.
|
|
*/
|
|
function publicThread(row, viewer = null) {
|
|
const author = displayAuthor({ userId: row.created_by, username: row.created_username }, viewer)
|
|
return {
|
|
id: row.id,
|
|
type: row.type,
|
|
title: row.title,
|
|
author: author.name,
|
|
authorDeleted: author.deleted,
|
|
createdAt: row.created_at,
|
|
lastPostAt: row.last_post_at,
|
|
postCount: row.post_count,
|
|
pinned: Boolean(row.pinned),
|
|
locked: Boolean(row.locked),
|
|
status: row.status,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* May this viewer edit this post, and until when?
|
|
*
|
|
* **Computed on the server and handed to the client, never the other way round** —
|
|
* the same rule §5.5.3 applies to the image policy, for the same reason. A client
|
|
* that decided this would be deciding it against its own clock, and a clock is the
|
|
* one input a time-bounded permission must not take from the party it bounds.
|
|
*
|
|
* Staff get `editableUntil: null`, which reads as "no deadline" rather than as "no
|
|
* permission" — `canEdit` is the permission and this is only its expiry. An author
|
|
* past their window keeps a past `editableUntil`, so the UI can say *why* the
|
|
* control is gone instead of silently dropping it.
|
|
*/
|
|
function editability(row, { userId = null, isStaff = false, windowMinutes = 0, now = Date.now() } = {}) {
|
|
// A hidden or deleted post is not editable by anybody, staff included. Restoring
|
|
// it is a moderation action with a ledger row; quietly rewriting it while it is
|
|
// out of sight is the same act with no record.
|
|
if (row.status !== 'visible') return { canEdit: false, editableUntil: null }
|
|
if (isStaff) return { canEdit: true, editableUntil: null }
|
|
if (!userId || row.author_user_id == null || row.author_user_id !== userId) {
|
|
return { canEdit: false, editableUntil: null }
|
|
}
|
|
const until = new Date(row.created_at).getTime() + windowMinutes * 60_000
|
|
return { canEdit: until > now, editableUntil: new Date(until).toISOString() }
|
|
}
|
|
|
|
/**
|
|
* One post, rendered for one image policy and one viewer.
|
|
*
|
|
* `body` is what the reader gets and `mode` decides whether it carries images.
|
|
* The STORED html is never modified — flipping the policy changes this function's
|
|
* output and nothing on disk, which is the property §5.5.3 exists to give and the
|
|
* one acceptance criterion 3 measures.
|
|
*
|
|
* `viewer` is optional so that every 5a caller keeps working unchanged; omitting
|
|
* it yields `canEdit: false`, which is the right answer for a caller that has not
|
|
* said who is reading.
|
|
*/
|
|
function renderPost(row, mode, viewer) {
|
|
const author = displayAuthor({ userId: row.author_user_id, username: row.author_username }, viewer)
|
|
return {
|
|
id: row.id,
|
|
author: author.name,
|
|
authorDeleted: author.deleted,
|
|
body: renderForumBody(row.body_html, mode),
|
|
createdAt: row.created_at,
|
|
editedAt: row.edited_at,
|
|
status: row.status,
|
|
mine: Boolean(viewer?.userId) && row.author_user_id === viewer.userId,
|
|
...editability(row, viewer),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The thread list for one viewer.
|
|
*
|
|
* `canModerate` widens what is returned, not just what is offered: a hidden
|
|
* thread is visible to the people who can unhide it and to nobody else, so the
|
|
* same call answers both audiences without a second endpoint that could disagree
|
|
* with this one.
|
|
*/
|
|
async function listThreads(teamId, { canModerate = false, viewer = null, filter = null, limit = 50, offset = 0 } = {}) {
|
|
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
|
|
return rows.filter((row) => !leftOutThread(row, filter)).map((row) => publicThread(row, viewer))
|
|
}
|
|
|
|
// ── one reader's blocks and hides (USER_SAFETY.md §4.1) ───────────────────
|
|
//
|
|
// `filter` is `userSafety.forumFilter(userId)`: the accounts this reader blocks
|
|
// and the threads and posts they hid. Applied on the server, to this reader's
|
|
// read only — nothing on disk changes, and nobody else's view does (S9).
|
|
|
|
/** Does this reader's filter leave this thread out? */
|
|
function leftOutThread(row, filter) {
|
|
if (!filter) return false
|
|
if (filter.hidden.team_forum_thread.has(Number(row.id))) return true
|
|
return row.created_by != null && filter.blockedUserIds.has(Number(row.created_by))
|
|
}
|
|
|
|
/**
|
|
* May this reader block these authors? Decided here and handed to the client,
|
|
* like `canEdit`: not yourself, not a deleted account, and not staff (S8) —
|
|
* the menu does not offer what the server would refuse.
|
|
*/
|
|
async function blockable(viewer, authorIds) {
|
|
const me = viewer && viewer.userId
|
|
if (!me) return () => false
|
|
const ids = [...new Set(authorIds.filter((id) => id != null && id !== me).map(Number))]
|
|
const staff = new Set(ids.length ? await safetyDb.staffAmong(ids, STAFF_ROLES) : [])
|
|
return (id) => id != null && Number(id) !== Number(me) && !staff.has(Number(id))
|
|
}
|
|
|
|
/** Does this reader's filter leave this post out? */
|
|
function leftOutPost(row, filter) {
|
|
if (!filter) return false
|
|
if (filter.hidden.team_forum_post.has(Number(row.id))) return true
|
|
return row.author_user_id != null && filter.blockedUserIds.has(Number(row.author_user_id))
|
|
}
|
|
|
|
/**
|
|
* One thread with its posts, rendered under the current image policy and for one
|
|
* viewer.
|
|
*
|
|
* `viewer` carries who is reading and what the edit window is, so every post comes
|
|
* back already knowing whether this caller may edit it. The alternative — shipping
|
|
* the window to the client and letting it compare timestamps — is the thing
|
|
* `editability` exists not to do.
|
|
*/
|
|
async function getThread(teamId, threadId, { canModerate = false, viewer, filter = null } = {}) {
|
|
const thread = await forumDb.threadById(threadId)
|
|
// The team check is here rather than in the SQL so a thread id from another
|
|
// Team reads as "not found" and not as "found, but not yours" — a forum is a
|
|
// private room and the existence of a thread in it is itself private.
|
|
if (!thread || thread.team_id !== teamId) return null
|
|
if (thread.status === 'deleted' && !canModerate) return null
|
|
if (thread.status === 'hidden' && !canModerate) return null
|
|
// A thread this reader hid, or opened by someone they block, is gone from
|
|
// their view — a link to it included.
|
|
if (leftOutThread(thread, filter)) return null
|
|
|
|
const mode = await forumSettings.imageMode()
|
|
const posts = (await forumDb.postsByThread(threadId, { includeHidden: canModerate }))
|
|
.filter((p) => !leftOutPost(p, filter))
|
|
const canBlock = await blockable(viewer, [thread.created_by, ...posts.map((p) => p.author_user_id)])
|
|
return {
|
|
...publicThread(thread, viewer),
|
|
canBlock: canBlock(thread.created_by),
|
|
// A reply control is offered when the TYPE takes replies and the thread is
|
|
// open. Both halves are reported separately (`type`, `locked`) so the UI can
|
|
// say which one is why, but the decision itself is made here — a client that
|
|
// recomputed it would be a second place for the rule to live.
|
|
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
|
|
posts: posts.map((p) => ({ ...renderPost(p, mode, viewer), canBlock: canBlock(p.author_user_id) })),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a thread: the thread and its first post, in one call.
|
|
*
|
|
* An announcement is a degenerate thread rather than its own thing (§5.1), which
|
|
* is why phase 5 added no migration — a discussion thread is the same two writes
|
|
* with a different `type`. The FIRST post is an ordinary post and is moderated,
|
|
* edited and reported like any other; nothing here marks it as special, because a
|
|
* thread whose opening post could not be moderated would be a hole shaped exactly
|
|
* like the one moderation exists to close.
|
|
*/
|
|
async function createThread({ team, actor, type, title, body }) {
|
|
if (!CREATABLE_TYPES.includes(type)) {
|
|
return { ok: false, status: 400, error: 'Unknown thread type' }
|
|
}
|
|
const cleaned = cleanForumBody(body)
|
|
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
|
return { ok: false, status: 400, error: 'A post needs a body' }
|
|
}
|
|
const threadId = await forumDb.insertThread({
|
|
teamId: team.id,
|
|
type,
|
|
title,
|
|
createdBy: actor.id,
|
|
createdUsername: actor.username,
|
|
})
|
|
const postId = await forumDb.insertPost({
|
|
threadId,
|
|
authorUserId: actor.id,
|
|
authorUsername: actor.username,
|
|
bodyHtml: cleaned,
|
|
})
|
|
// `notify` is what the CONTROLLER needs to fan a notification out, and it is a
|
|
// separate key rather than more fields on the result because the controller
|
|
// spreads the result straight into the response body — a notification's excerpt
|
|
// is not part of the API's answer to "did my post save".
|
|
//
|
|
// The notification itself is fired from the controller and not from here, on
|
|
// this file's own rule (see the header): everything in it takes an
|
|
// already-resolved access decision and reads no membership table. The fan-out
|
|
// reads both, so importing it here would make the forum model transitively
|
|
// depend on exactly what it exists not to touch.
|
|
return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } }
|
|
}
|
|
|
|
/**
|
|
* Reply to a discussion thread.
|
|
*
|
|
* Three refusals, and the status codes are chosen to be distinguishable rather
|
|
* than uniform. A thread that is not there, or is hidden from this caller, is 404
|
|
* for the §5.5.1 reason. An announcement is 400 — the request is malformed for
|
|
* this thread, and no amount of retrying fixes it. A locked thread is **409**: the
|
|
* request is fine and the resource's state is what refuses, which is exactly the
|
|
* distinction a client needs to tell "you cannot" from "not right now".
|
|
*
|
|
* **Locked refuses staff too.** They hold `unlock`, so nothing is lost — and what
|
|
* is gained is that `locked` means the same thing to every reader. A moderator's
|
|
* reply appearing in a thread nobody else may answer is the last word by fiat;
|
|
* unlock, post, relock is the same outcome with three ledger rows saying so.
|
|
*/
|
|
async function createPost({ team, threadId, actor, body }) {
|
|
const thread = await forumDb.threadById(threadId)
|
|
if (!thread || thread.team_id !== team.id || thread.status !== 'visible') {
|
|
return { ok: false, status: 404, error: 'Thread not found' }
|
|
}
|
|
if (!REPLYABLE_TYPES.includes(thread.type)) {
|
|
return { ok: false, status: 400, error: 'Announcements do not take replies' }
|
|
}
|
|
if (thread.locked) {
|
|
return { ok: false, status: 409, error: 'This thread is locked' }
|
|
}
|
|
const cleaned = cleanForumBody(body)
|
|
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
|
return { ok: false, status: 400, error: 'A reply needs a body' }
|
|
}
|
|
const postId = await forumDb.insertPost({
|
|
threadId,
|
|
authorUserId: actor.id,
|
|
authorUsername: actor.username,
|
|
bodyHtml: cleaned,
|
|
})
|
|
// The thread's OWN title and type, not the reply's — a reply has neither, and
|
|
// what a recipient needs to know is which conversation moved. `type` is always
|
|
// 'discussion' here (an announcement takes no replies) and is carried anyway so
|
|
// the controller has one shape to hand the fan-out from both routes.
|
|
return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } }
|
|
}
|
|
|
|
/**
|
|
* Edit a post: the author inside the window, staff at any time (§5.4).
|
|
*
|
|
* The window is re-derived HERE from `created_at` and never trusted from the
|
|
* request, which is also why `editability` runs on the read path — the read tells
|
|
* the client whether to draw the control, and this decides whether the edit
|
|
* happens. Two evaluations of one rule, deliberately: the read one is advice and
|
|
* this one is enforcement.
|
|
*
|
|
* A staffer editing someone else's post is reported back as `staffEdit` so the
|
|
* controller can write the §5.3 accountability row. A staffer editing their OWN
|
|
* post is an ordinary edit and is not: the trail records interventions, and
|
|
* everything a staffer ever typed is not an intervention.
|
|
*/
|
|
async function editPost({ team, postId, actor, isStaff = false, windowMinutes = 0, body }) {
|
|
const post = await forumDb.postById(postId)
|
|
if (!post) return { ok: false, status: 404, error: 'Post not found' }
|
|
|
|
const thread = await forumDb.threadById(post.thread_id)
|
|
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
|
|
if (post.status !== 'visible' || thread.status !== 'visible') {
|
|
return { ok: false, status: 404, error: 'Post not found' }
|
|
}
|
|
|
|
const isAuthor = post.author_user_id != null && post.author_user_id === actor.id
|
|
if (!isAuthor && !isStaff) {
|
|
return { ok: false, status: 403, error: 'You may only edit your own posts' }
|
|
}
|
|
if (!isStaff) {
|
|
if (thread.locked) return { ok: false, status: 409, error: 'This thread is locked' }
|
|
const { canEdit } = editability(post, { userId: actor.id, windowMinutes })
|
|
if (!canEdit) {
|
|
return {
|
|
ok: false,
|
|
status: 403,
|
|
error: windowMinutes > 0
|
|
? `The ${windowMinutes}-minute edit window for this post has closed`
|
|
: 'Posts cannot be edited on this site',
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleaned = cleanForumBody(body)
|
|
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
|
return { ok: false, status: 400, error: 'A post needs a body' }
|
|
}
|
|
await forumDb.updatePostBody(postId, cleaned, actor.id)
|
|
return { ok: true, postId, threadId: post.thread_id, staffEdit: isStaff && !isAuthor }
|
|
}
|
|
|
|
/**
|
|
* Apply a moderation action to a thread, and record WHICH authority did it.
|
|
*
|
|
* `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary
|
|
* housekeeping distinguishable from a staff intervention after the fact (§5.3).
|
|
* The caller resolves it; this function records it and never infers it, because
|
|
* an actor who is both would otherwise be recorded as whichever the code checked
|
|
* first.
|
|
*/
|
|
async function moderateThread({ team, threadId, action, actor, actorRole, reason }) {
|
|
const effect = THREAD_ACTIONS[action]
|
|
if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' }
|
|
|
|
const thread = await forumDb.threadById(threadId)
|
|
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' }
|
|
|
|
await forumDb.setThreadFlags(threadId, effect)
|
|
await forumDb.insertModeration({
|
|
teamId: team.id,
|
|
targetType: 'thread',
|
|
targetId: threadId,
|
|
action,
|
|
actorUserId: actor.id,
|
|
actorUsername: actor.username,
|
|
actorRole,
|
|
reason,
|
|
})
|
|
return { ok: true, action, threadId }
|
|
}
|
|
|
|
/**
|
|
* Apply a moderation action to a POST, and record which authority did it.
|
|
*
|
|
* The same ledger as `moderateThread`, with `target_type='post'` — one table, two
|
|
* target kinds, because "show me everything that was moderated in this Team" is
|
|
* the question the admin view asks and two tables would make it a union.
|
|
*
|
|
* `pin` and `unpin`, `lock` and `unlock` are refused with a message that names the
|
|
* mistake rather than a bare "unknown action": they are real actions applied to
|
|
* the wrong kind of object, and a caller who sent one has a bug worth telling
|
|
* them about precisely.
|
|
*
|
|
* **The opening post of a thread is moderatable like any other.** Hiding it leaves
|
|
* a thread with a title and its replies and no body, which looks odd and is
|
|
* correct — an abusive opener does not have to take a good discussion with it, and
|
|
* a moderator who wants the whole thing gone has `hide` on the thread.
|
|
*/
|
|
async function moderatePost({ team, postId, action, actor, actorRole, reason }) {
|
|
const effect = POST_ACTIONS[action]
|
|
if (!effect) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
error: THREAD_ACTIONS[action]
|
|
? `"${action}" applies to a thread, not to a post`
|
|
: 'Unknown moderation action',
|
|
}
|
|
}
|
|
|
|
const post = await forumDb.postById(postId)
|
|
if (!post) return { ok: false, status: 404, error: 'Post not found' }
|
|
const thread = await forumDb.threadById(post.thread_id)
|
|
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
|
|
|
|
await forumDb.setPostStatus(postId, effect.status)
|
|
// The counters are recomputed rather than nudged, because these four actions
|
|
// form cycles (hide → unhide → hide) that a delta gets wrong the first time one
|
|
// is retried.
|
|
await forumDb.recountThread(post.thread_id)
|
|
|
|
// Images follow their post. Soft on the way out and reversible on the way back
|
|
// in, so `delete` → `restore` inside the retention window returns the post
|
|
// whole; past it, the sweep has taken the bytes and nothing can.
|
|
if (action === 'delete') await forumDb.softDeleteUploadsForPost(postId, actor.id)
|
|
if (action === 'restore') await forumDb.restoreUploadsForPost(postId)
|
|
|
|
await forumDb.insertModeration({
|
|
teamId: team.id,
|
|
targetType: 'post',
|
|
targetId: postId,
|
|
action,
|
|
actorUserId: actor.id,
|
|
actorUsername: actor.username,
|
|
actorRole,
|
|
reason,
|
|
})
|
|
return { ok: true, action, postId, threadId: post.thread_id }
|
|
}
|
|
|
|
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
|
|
async function moderationLedger(teamId, opts) {
|
|
return forumDb.moderationForTeam(teamId, opts)
|
|
}
|
|
|
|
module.exports = {
|
|
CREATABLE_TYPES,
|
|
CREATABLE_TYPES_5A,
|
|
REPLYABLE_TYPES,
|
|
THREAD_ACTIONS,
|
|
POST_ACTIONS,
|
|
listThreads,
|
|
getThread,
|
|
createThread,
|
|
createPost,
|
|
editPost,
|
|
moderateThread,
|
|
moderatePost,
|
|
moderationLedger,
|
|
publicThread,
|
|
renderPost,
|
|
editability,
|
|
leftOutThread,
|
|
leftOutPost,
|
|
}
|