Architecture and contracts
Security model
- The dashboard requires Discord OAuth2 with the
identifyandguildsscopes. - OAuth login uses a cryptographically random
statevalue stored in the session and validated on callback. - Mochi lists only servers that the user can manage and that Mochi has joined. Per-server routes return
403for other servers. - Discord provides the owner for each server. Mochi does not need a global owner ID.
- Mochi has no implicit development admin.
/auth/logincan create a “Development Admin” session only whenAPP_MODE=development,DEV_AUTH_BYPASS=true, and the request comes from the local host. The session is logged. Without the bypass, missing OAuth settings make login unavailable.DEV_AUTH_BYPASS=trueis forbidden in production. The application does not start when it is set there. - Mochi fetches server-management permissions from Discord at login. It stores
them in the session until
GUILD_PERMISSION_CACHE_TTL_SECONDSexpires. Mochi refreshes them before protected operations. A revoked or invalid OAuth authorization returns401. Mochi does not use stale permissions. - OAuth access and refresh tokens stay on the server in
session.discordOAuth. Mochi uses them to refresh access. Mochi never sends them to browser JavaScript, returns them in API JSON, or writes them to logs./auth/userreturns only safe public user fields. - All
/api/guilds/**endpoints require authentication and per-server authorization./api/statsprovides non-sensitive global telemetry./api/healthis public for health checks. All other/apiroutes are protected. - Socket.IO uses the shared Express session for authentication. Mochi authorizes room membership per server. It sends events only to the authorized server room.
- Session cookies are
httpOnly,sameSite=lax, andsecurein production. The cookie name is non-default:mochi.sid. - Sessions are stored on the server in SQLite at
SESSION_STORE_PATH(./data/mochi-sessions.sqliteby default). PM2 restarts do not force users to log in again. Keep this database on persistent storage. Horizontally scaled deployments need a shared session store instead of the local SQLite file.
Realtime transport contract
Socket.IO forwards canonical application events to authorized server rooms. The
transport DTOs are defined in src/dashboard/realtime/eventMappers.js. The
frontend uses these exact shapes. The payloads have no user or member
aliases.
memberJoin and memberLeave
{
"guildId": "...",
"member": { "id": "...", "username": "...", "avatar": "..." },
"attribution": { "type": "INVITE", "inviterId": "...", "inviteCode": "..." },
"inviter": { "id": "...", "username": "...", "avatar": "..." },
"isFake": false,
"inviterStats": { "regular": 4, "bonus": 1, "leaves": 1, "fake": 0, "total": 4 },
"occurredAt": "..."
}
inviteCreated
{
"guildId": "...",
"invite": {
"code": "...",
"url": "...",
"uses": 0,
"maxUses": 0,
"maxAge": 0,
"temporary": false,
"channelId": "...",
"channelName": "...",
"inviter": { "id": "...", "username": "..." },
"createdAt": "...",
"label": null
},
"occurredAt": "..."
}
Other event shapes:
inviteDeleted→{ guildId, code, occurredAt }inviteLabelUpdated→{ guildId, code, label, channelId, channelName, occurredAt }autoModExecution→ flat{ guildId, ruleId, ruleName, action, user, ... }autoModRuleUpdated→{ guildId, action, ruleId, name, enabled }
Data responsibilities
The database layer separates durable facts from rebuildable read models:
invite_events + invite_bonus_adjustments
│
├── inviter_stats (canonical net totals)
├── invite_members
├── inviters
└── daily_invite_stats
The invite cache is a fallback for Discord query failures. It is not a second source of truth. See Invite tracking and Deployment and operations for domain rules and maintenance commands.