Account Moderators — Technical Reference
Where this lives
Backend
apps/backend/database/models/AccountModerator.js— model (owner/moderator + 9 permission flags)apps/backend/database/models/AccountModeratorAction.js— audit-trail modelapps/backend/database/migrations/20260721130000-create-account-moderator.js—account_moderatortable (original 5 flags)apps/backend/database/migrations/20260721150000-expand-account-moderator.js— addscan_comment/can_manage_comments/can_story/can_react+ createsaccount_moderator_actionapps/backend/data-access-services/moderator/account-moderator.access-service.js— CRUD +getByOwner/getByModeratorapps/backend/data-access-services/moderator/account-moderator-action.access-service.js—create+getByOwner(audit feed, paginated, max 200)apps/backend/managers/user-managers/account-moderator.manager.js— 2FA gate (assertOwner2FA), step-up re-auth (assertRecentAuth/verifyPasswordStepUp, backed byservices/step-up-auth.service.js— a 15-minute Redis-backed "sudo window"),canActFor,resolveActingUser,resolveAndLog,logAction,getModeratorActivityapps/backend/graphql/types/account-moderator.type.js/resolvers/account-moderator.resolver.js- Enforcement (
actAsUserId):post.resolver.js(createPostpost/clip, andcreateStory),post-interaction.resolver.js(likePost),live-stream.resolver.js,conversation.resolver.js,message.resolver.js,post-comment.resolver.js(createComment,deleteComment).createStorynow takesactAsUserIdand enforcescanStory,likePostenforcescanReact, anddeleteCommentenforcescanManageComments— this doc previously said all three were defined but never checked; corrected. - Message attribution:
Message.viaModeratorresolver inmessage.resolver.jsreadsmetadata.viaModeratorId, visible only to the effective sender (the owner).
Frontend
apps/frontend-nextjs/src/page-components/settings/ModeratorsPage.tsx— Settings → Moderadores (2FA-gated management, 9 permission toggles,moderatorActivityfeed, step-up re-auth password prompt onREQUIRES_REAUTH)apps/frontend-nextjs/src/components/CreatePostModal.tsx— "Publicar como" selector (accountsIModerate→actAsUserId)apps/frontend-nextjs/src/components/PostModal.tsx— "Comentar como" selector oncreateComment(accountsIModeratefiltered bycanComment→actAsUserId)apps/frontend-nextjs/src/components/live/GoLiveModal.tsx— "acting as" selector oncreateLiveStream(accountsIModeratefiltered bycanLive→actAsUserId)apps/frontend-nextjs/src/components/chat/ConversationList.tsx— "acting as" selector on the new-chat/groupcreateConversationflow (accountsIModeratefiltered bycanCreateChats→actAsUserId)apps/frontend-nextjs/src/components/chat/ChatView.tsx— "acting as" selector on the message composer (accountsIModeratefiltered bycanSendMessages→actAsUserIdon everysendMessagecall)
Technical implementation checklist
-
account_moderatortable + model + associations (owner,moderator) — 9 permission flags:canPost,canClip,canLive,canStory,canCreateChats,canSendMessages,canComment,canReact,canManageComments -
account_moderator_actiontable + model — audit trail of every action performed on an owner's behalf -
addModerator/updateModeratorPermissions— requireowner.twoFactorEnabled(throwmoderators.requires_2faotherwise) and a fresh step-up auth window (throwmoderators.requires_reauth/REQUIRES_REAUTHotherwise) -
verifyPasswordForModerators(password)— re-verifies the owner's password and opens a fresh 15-minute step-up window -
removeModerator— no 2FA or step-up required -
myModerators/accountsIModerate/moderatorActivityqueries -
resolveActingUser(callerId, actAsUserId, action)enforcement oncreatePost(post/clip),createStory,likePost,createLiveStream,createConversation,sendMessage,createComment,deleteComment -
logActionwrites to the audit trail whenever the effective user differs from the caller (best-effort, never throws) -
Message.viaModerator— owner-only attribution of who really sent a message sent "as" them - "Publicar como" in the post composer, "Comentar como" in the comment box
-
canStoryenforcement —createStoryacceptsactAsUserIdand callsresolveActingUser(..., 'story', ...); this doc previously saidcanStorywas defined end-to-end but never enforced. Corrected. -
canReact/canManageCommentsenforcement —likePost(post-interaction.resolver.js) callsresolveActingUser(..., 'react', ...)anddeleteComment(post-comment.resolver.js) callsresolveActingUser(..., 'manage_comment', ...); this doc previously said neither permission was ever checked by any resolver. Corrected. - Acting-as UI trigger on the live / new-chat / message composers —
GoLiveModal.tsx(filtered bycanLive),ConversationList.tsx(filtered bycanCreateChats), andChatView.tsx(filtered bycanSendMessages) all now expose an "acting as" selector wired toactAsUserId, the same patternCreatePostModal/PostModalalready used; this doc previously said only posts/clips/comments were wired in the UI. Corrected.
GraphQL
type ModeratorPermissions {
canPost: Boolean! canClip: Boolean! canLive: Boolean! canCreateChats: Boolean! canSendMessages: Boolean!
canComment: Boolean! canManageComments: Boolean! canStory: Boolean! canReact: Boolean!
}
type AccountModerator {
id: ID!
owner: User
moderator: User
canPost: Boolean! canClip: Boolean! canLive: Boolean! canCreateChats: Boolean! canSendMessages: Boolean!
canComment: Boolean! canManageComments: Boolean! canStory: Boolean! canReact: Boolean!
createdAt: DateTime! updatedAt: DateTime!
}
input ModeratorPermissionsInput {
canPost: Boolean canClip: Boolean canLive: Boolean canCreateChats: Boolean canSendMessages: Boolean
canComment: Boolean canManageComments: Boolean canStory: Boolean canReact: Boolean
}
"A single entry in the account-moderator audit trail (who did what, when)."
type ModeratorAction {
id: ID!
moderator: User
action: String! # post | clip | live | story | create_chat | send_message | comment | manage_comment | react
actedAs: String! # owner | self
targetType: String # post | live_stream | conversation | message | comment
targetId: ID
metadata: JSON
createdAt: DateTime!
}
type Query {
myModerators: [AccountModerator!]!
accountsIModerate: [AccountModerator!]!
moderatorActivity(limit: Int, offset: Int, action: String, moderatorUserId: ID): [ModeratorAction!]!
}
type Mutation {
addModerator(userId: ID!, permissions: ModeratorPermissionsInput!): AccountModerator! # 2FA + step-up required
updateModeratorPermissions(userId: ID!, permissions: ModeratorPermissionsInput!): AccountModerator! # 2FA + step-up required
removeModerator(userId: ID!): Boolean!
verifyPasswordForModerators(password: String!): Boolean! # step-up re-auth
}
# The create/send inputs gain: actAsUserId: ID
# PostCreateInput / StoryCreateInput / LiveStreamCreateInput / ConversationCreateInput / MessageCreateInput / PostCommentCreateInput
# likePost(postId, interactionType, actAsUserId) and deleteComment(commentId, actAsUserId) take actAsUserId
# as a plain argument (not via an input type)
Permission → action map
| Permission | Action | Resolver |
|---|---|---|
canPost | regular post | createPost |
canClip | single-video clip | createPost (clip detected) |
canLive | start a live | createLiveStream |
canCreateChats | create a conversation | createConversation |
canSendMessages | send a message | sendMessage |
canComment | comment / reply on a post | createComment (post-comment.resolver.js) |
canStory | post a story | createStory |
canReact | react/like | likePost (post-interaction.resolver.js) |
canManageComments | moderate/delete a comment | deleteComment (post-comment.resolver.js) |
Configuration
Run 20260721130000-create-account-moderator and 20260721150000-expand-account-moderator, then restart the backend. No new environment variables. Owner must enable 2FA (Settings → Security) before granting moderator access; adding/updating moderators also requires a fresh step-up window (services/step-up-auth.service.js, Redis-backed, 15 minutes), which the owner can (re)open via verifyPasswordForModerators.