19 Commits

Author SHA1 Message Date
2b4c4c5235 Merge branch 'main' into feature/moderation-dashboard 2026-07-05 20:51:16 +00:00
3027bb0400 Capture member/filter/spam events for the dashboard (Phase 6b)
Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.

Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
  attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections

Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
  keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
  diffs it on join to find which invite was used — best-effort, never blocks
  auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
  inviteFilter now returns the offending code; detectSpam identifies which spam
  rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive

Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}

Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
  notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
  removed the coming-soon note

Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:36:09 -05:00
b0c0d1fe9b Add moderation dashboard, user history & notes (Phase 6a)
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.

Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)

Server
- model/moderation: read mod_actions via the shared pool (documented read-only
  cross of the bot/server ownership boundary), correlate accounts through
  user_identities (provider='discord'), flag automated actions via
  staff_user_id === bot_config.application_id; pure reshaping helpers isolated
  in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
  requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators

Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
  lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges

Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.

Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 10:16:34 -05:00
f2691959ff Merge pull request 'Fix bot container inheriting site PORT/LOG_FILE from shared .env' (#41) from bugfix/bot-container-port-leak into main
Reviewed-on: UOM/website#41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 05:40:29 +00:00
60d2121b83 Fix bot container inheriting site PORT/LOG_FILE from shared .env
The app and bot services share env_file: .env, so the site's PORT=3000
leaked into the bot container. The bot code is `PORT || 4100`, so it
bound 3000 instead of 4100 — and the server's BOT_INTERNAL_URL
(http://bot:4100) then couldn't reach it, surfacing as "failed to fetch"
on the admin Discord Bot page even though the bot was otherwise healthy
and connected to Discord.

Pin PORT: 4100 on the bot service so it binds where the server expects.
Also override LOG_FILE: bot.log so the bot doesn't inherit the site's
LOG_FILE and write into app.log, keeping the two logs distinct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-05 00:39:16 -05:00
20d3fbf594 Merge pull request 'Audit and fix Swagger/OpenAPI accuracy; regenerate served spec' (#40) from docs/swagger-audit into main
Reviewed-on: UOM/website#40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 04:33:36 +00:00
f8db61025b Audit and fix Swagger/OpenAPI accuracy; regenerate served spec
The route-level annotations were 100% present, but the committed/served
spec (swagger-output.json) was stale and several response schemas had
drifted from the controllers. This aligns the docs with actual behavior
and regenerates the spec.

Served spec was stale (64/67 operations). Regenerating picks up three
routes that were added after the last generation:
  - POST /api/v1/auth/sso/totp
  - GET  /api/v1/admin/discord-bot/config
  - PUT  /api/v1/admin/discord-bot/config
plus a stale /auth/logout summary.

Response-shape corrections (annotation now matches controller output):
  - Mutation endpoints do NOT return the generic { message } envelope.
    Deletes echo { id } / { slug }; toggles return { deleted },
    { unlinked }, { totp_enabled }, or { ip, removed }. Documented as-is
    via new DeletedId/DeletedSlug/DeletedFlag/UnlinkedFlag/TotpState/
    UnbanResult components. (The API is intentionally inconsistent here;
    recorded rather than normalized — see follow-up note.)
  - POST /account/totp/setup: otpauth_url -> otpauthUrl (TotpSetup)
  - PUT  /admin/site-mode: { mode } -> { site_mode, changed_at, changed_by }
  - GET  /account: full User -> AccountStatus (id/username/role/totp_enabled)
  - GET  /account/identities: add linked_at (LinkedIdentity)
  - GET  /public/status: add status_message (PublicStatus)
  - POST /auth/sso/totp: user is SafeUser, not full User
  - GET  /dashboard: description/shape corrected (posts+users, no wiki)

Schema completeness:
  - Provider (public discovery): { id, name, icon, loginUrl, priority },
    not { id, name, kind }
  - ProviderConfig: add hasSecret, builtin, health (ProviderHealth)
  - Post: add excerpt, author_id, published_at
  - MobileTokenResponse.expiresIn: duration string ("15m"), not integer

Config: declare the Admin · Discord Bot tag (was used but undeclared).

Auth model and the internal/external boundary were verified correct and
left unchanged: cookie + bearer are both accepted on session routes (dual
security annotations are accurate), and /internal/* runs on a separate
listener already excluded from the scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:51:16 -05:00
2067028070 Merge pull request 'Enforce TOTP second factor on SSO login (#31)' (#39) from bugfix/sso-totp-bypass-31 into main
Reviewed-on: UOM/website#39
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 03:34:49 +00:00
03e62b56ad Enforce TOTP second factor on SSO login (#31)
SSO login minted a full session immediately, ignoring the account's
totp_enabled flag — so a 2FA admin with a linked Google/Discord/OIDC
identity could sign in without their authenticator code, silently
downgrading the account to single-factor (the strength of the IdP login).
The local password flow already gates on needsTotp(); SSO did not.

Wire SSO through the same staged-TOTP gate:

- ssoState: createTotpPending/verifyTotpPending + a short-lived httpOnly
  sso_totp cookie. The pending token carries stage:'totp' (session
  validation rejects it) + kind:'sso_totp' (scoped to the SSO endpoint)
  plus the resolved context (userId, provider, authMethod, returnTo).
- sso.controller: finishLogin now stages the challenge and redirects to
  /admin/login?sso_totp=1 instead of creating a session when the account
  has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring
  on failure, mirroring loginTotp) and only then mints the session.
- sso.routes: POST /auth/sso/totp behind the same backoff/slow/limiter
  stack and code validation as the local TOTP endpoint.
- client: AdminLogin detects ?sso_totp=1 and completes over fetch via
  api.ssoLoginTotp; the challenge never touches the URL or JS.

Keeps the second factor httpOnly throughout, consistent with the SSO tx
cookie. 12 new tests; full suite 106/106.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:17:35 -05:00
15cf8ea286 Merge pull request 'Fix SSO flow-token / session type confusion (#32)' (#38) from bugfix/sso-token-confusion-32 into main
Reviewed-on: UOM/website#38
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 02:56:31 +00:00
5f62eccdd8 Fix SSO flow-token / session type confusion (#32)
sessionFromDecoded validated sessions with a blocklist — it rejected a
token only when `decoded.stage` was present (the TOTP challenge). Because
every JWT is signed with the same JWT_SECRET and distinguished only by
claims, the SSO transaction cookie (sso_tx, which carries kind:'sso_tx'
and id:'sso' but no stage) passed validation and was accepted as a bogus
{ userId:'sso' } session.

requireAuth's DB re-load blocked protected admin routes, but non-DB
identity checks were fooled — notably siteMode's maintenance-preview
bypass, which trusts any truthy getUserFromRequest. An attacker could
start an SSO flow to obtain an sso_tx cookie and replay it as the auth
cookie / Bearer token to bypass the maintenance gate. The broader risk
was latent: any future code path trusting attachSession/getUserFromRequest
without a DB round-trip inherited an auth bypass.

Make session validation positively typed: real sessions are now stamped
with typ:'session' (createSession + mintMobileTokens), and
sessionFromDecoded accepts a token only when that marker is present. As
belt-and-suspenders it also rejects any token carrying a non-session
marker (stage || kind). Flow/challenge tokens are never stamped, so they
can no longer be mistaken for sessions.

Note: existing web cookie sessions predating this change lack the typ
claim and will be rejected once — users re-login. Mobile clients recover
automatically on next refresh.

Adds regression tests: the sso_tx flow token and a bare identity token
are both rejected by validateSession / decodeIdentity / getUserFromRequest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:54:58 -05:00
e8a54d9ff7 Merge pull request 'Implement web session/token revocation (#30)' (#37) from bugfix/session-revocation-30 into main
Reviewed-on: UOM/website#37
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 02:08:15 +00:00
933206a1b8 Implement web session/token revocation (#30)
Web sessions were stateless JWTs with no server-side store: the revocation
hooks in session.service were stubs that only logged. As a result web logout
was client-side only (a copied cookie stayed valid until natural JWT expiry)
and a password change never invalidated existing sessions. The mobile bearer
flow already had revocable, DB-stored tokens; this brings the web/cookie flow
to parity.

Two-layer revocation, both enforced in requireAuth (which already loads the
fresh user row each request):

- Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti`
  (already minted per session). A single logout adds this session's jti;
  rows self-expire at the token's own exp and are pruned on boot. New model
  `revokedSessions` mirrors the `mobileSessions` db/model split.
- Per-user cutoff: new `users.tokens_valid_after` column. A password change
  (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose
  iat is at or before the cutoff is rejected. The comparison is inclusive so a
  token minted in the same wall-clock second as the change is still revoked.

Wiring:
- session.service: revokeSession / invalidateSession / invalidateAllUserSessions
  now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can
  set a self-pruning denylist row.
- /logout gains best-effort attachSession so the controller can revoke this
  session's jti and log auth.logout; stays a no-op for anonymous callers.
- users.model.update bumps the cutoff whenever the password hash is rotated.
- schema.sql: revoked_sessions table + tokens_valid_after column, added to the
  CREATE and to the idempotent migration block (ensureSchema on boot).

Verified end-to-end against the local dev DB: a captured cookie is rejected
after logout, and an existing session is rejected after a password change while
re-login with the new password succeeds. Full server test suite green (96).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 21:06:50 -05:00
1cfb79f5ae Merge pull request 'Isolate internal bot-config route from the public listener (#33)' (#36) from bugfix/internal-token-endpoint-33 into main
Reviewed-on: UOM/website#36
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 22:50:05 +00:00
3ef84b41ef Merge branch 'main' into bugfix/internal-token-endpoint-33 2026-07-04 22:49:33 +00:00
5df943095d Isolate internal bot-config route from the public listener (#33)
The GET /internal/bot-config route returns the DECRYPTED Discord bot
token and was mounted on the same Express app / port 3000 that Pangolin
proxies publicly. Its only guard was the BOT_INTERNAL_KEY shared secret,
and .env.example shipped a placeholder default — so a forwarded path or a
weak/unrotated key would expose the plaintext token to the internet.

Move server<->bot internal traffic onto its own listener and fail fast on
a weak key:

- Add server/src/internalApp.js: a standalone Express app mounting
  requireInternalKey + /internal (and a no-secret /health), mirroring the
  bot's unpublished port-4100 pattern.
- server.js starts a second listener on INTERNAL_PORT (default 3001),
  closed on graceful shutdown.
- Remove the /internal mount from the public v1.router; the public app now
  404s /api/v1/internal/bot-config even with a valid key.
- Fail fast: new utils/botInternalKey.js rejects an empty, placeholder, or
  <16-char BOT_INTERNAL_KEY — fatal in production (exit 1), warning in dev.
- docker-compose: bot SITE_INTERNAL_URL -> app:3001/internal/bot-config;
  document that INTERNAL_PORT stays unpublished.
- .env.example (root/server/bot): document INTERNAL_PORT, the fail-fast
  behavior, and a defense-in-depth Pangolin deny rule for /api/v1/internal.

Tests: add requireInternalKey.test.js and botInternalKey.test.js
(node --test: 93 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 17:35:07 -05:00
bb5cc68c54 Merge pull request 'Add Discord bot: moderation, filters, scheduling, roles, invites, site integration' (#29) from feature/discord-bot into main
Reviewed-on: UOM/website#29
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 21:19:41 +00:00
17c1eb07e8 Merge branch 'main' into feature/discord-bot 2026-07-04 21:19:23 +00:00
ad7aebb3ba Merge pull request 'Hero editor: fullscreen landing, remove two-card row, quick links into hero' (#28) from feature/hero-fullscreen-landing into main
Reviewed-on: UOM/website#28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-04 02:55:50 +00:00
57 changed files with 4083 additions and 158 deletions

View File

@@ -4,6 +4,10 @@
# App
NODE_ENV=production
PORT=3000
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
INTERNAL_PORT=3001
UPLOAD_DIR=/app/uploads
# Logging — written to BOTH the console and a log file.
LOG_LEVEL=info # console verbosity: error | warn | info | debug
@@ -64,7 +68,14 @@ CLIENT_ORIGIN=http://localhost:5173
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
# routes, so a mismatch silently breaks every server<->bot call with 401s.
# The Discord bot TOKEN itself is not an env var — it's entered in the admin
# panel (Discord Bot page) and stored encrypted in the DB (see bot_config table).
# It also guards the server's /internal/bot-config route, which returns the
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
# long random string. The Discord bot TOKEN itself is not an env var — it's
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
#
# Defense in depth: even with a strong key, configure Pangolin/your reverse
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
BOT_INTERNAL_URL=http://bot:4100
BOT_INTERNAL_KEY=change-me-to-a-long-random-string

View File

@@ -24,9 +24,10 @@ LOG_TO_FILE=true # set false for console-only
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
# Where this bot calls back to the main site to fetch its config on boot
# (GET .../api/v1/internal/bot-config), so a restart self-reconnects without
# needing the admin panel to push config again.
SITE_INTERNAL_URL=http://localhost:3000/api/v1/internal/bot-config
# (GET .../internal/bot-config), so a restart self-reconnects without needing
# the admin panel to push config again. This targets the site's UNPUBLISHED
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
# visitor's browser can fetch. Used by /wiki (search) and /announce

View File

@@ -9,6 +9,8 @@ const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler')
const { handleGuildMemberAdd } = require('./guildMemberAdd')
const { handleGuildMemberRemove } = require('./guildMemberRemove')
const inviteTracker = require('./inviteTracker')
const tempRoleSweeper = require('../roles/tempRoleSweeper')
const inviteScheduler = require('../invites/inviteScheduler')
@@ -58,13 +60,15 @@ async function start({ token, guildId: gid }) {
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
// in the Discord Developer Portal, see the Phase 1 setup notes.
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
],
})
@@ -74,6 +78,7 @@ async function start({ token, guildId: gid }) {
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
@@ -102,6 +107,10 @@ async function start({ token, guildId: gid }) {
client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove)
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
client.on('inviteCreate', inviteTracker.onInviteCreate)
client.on('inviteDelete', inviteTracker.onInviteDelete)
client.on('error', (err) => {
status = 'error'

View File

@@ -1,11 +1,37 @@
// Auto-role on join. Requires the Server Members privileged intent (already
// enabled in the Discord Developer Portal per the Phase 1 setup notes).
// Member join handling: record the join event (with best-effort invite
// attribution, Phase 6b) then apply the configured auto-role. Requires the
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
// and, for invite attribution, the GuildInvites intent.
const guildConfig = require('../model/guildConfig')
const memberEvents = require('../model/memberEvents')
const inviteTracker = require('./inviteTracker')
const createLogger = require('../utils/logger')
const log = createLogger('autorole')
const log = createLogger('members')
async function handleGuildMemberAdd(member) {
// Attribute the invite first (diffs the invite-use cache), then record the join.
// Both are best-effort — a failure here must never block the auto-role below.
let invite = { code: null, inviterId: null, inviterTag: null }
try {
invite = await inviteTracker.attribute(member)
} catch (err) {
log.warn('invite attribution threw', { userId: member.id, message: err.message })
}
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'join',
discordUserId: member.id,
username: member.user?.tag,
inviteCode: invite.code,
inviterId: invite.inviterId,
inviterTag: invite.inviterTag,
})
} catch (err) {
log.warn('member join record failed', { userId: member.id, message: err.message })
}
try {
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
if (!roleId) return

View File

@@ -0,0 +1,23 @@
// Member leave handling (Phase 6b): record a leave event for the dashboard's
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
// distinguish them on this event, and the mod-action (if any) is logged
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
const memberEvents = require('../model/memberEvents')
const createLogger = require('../utils/logger')
const log = createLogger('members')
async function handleGuildMemberRemove(member) {
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'leave',
discordUserId: member.id,
username: member.user?.tag,
})
} catch (err) {
log.warn('member leave record failed', { userId: member.id, message: err.message })
}
}
module.exports = { handleGuildMemberRemove }

View File

@@ -0,0 +1,74 @@
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
// which invite a member used, so the standard approach is to keep a cache of
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
// already creates/deletes invites, so it has the permission). All calls are
// best-effort: any failure just yields a null attribution and the join is still
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
const createLogger = require('../utils/logger')
const log = createLogger('invites')
// guildId -> Map<inviteCode, uses>
const cache = new Map()
async function snapshot(guild) {
const map = new Map()
const invites = await guild.invites.fetch()
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
return map
}
// Populate the cache for a guild (call once the client is ready).
async function prime(client, guildId) {
try {
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
cache.set(guildId, await snapshot(guild))
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
} catch (err) {
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
}
}
function onInviteCreate(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id) || new Map()
g.set(invite.code, invite.uses || 0)
cache.set(invite.guild.id, g)
}
function onInviteDelete(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id)
if (g) g.delete(invite.code)
}
// Diff current invite uses against the cached snapshot to find which invite the
// joining member used, then refresh the cache. Returns { code, inviterId,
// inviterTag } with nulls when it can't be determined.
async function attribute(member) {
const empty = { code: null, inviterId: null, inviterTag: null }
try {
const guild = member.guild
const before = cache.get(guild.id) || new Map()
const current = await guild.invites.fetch()
let found = empty
for (const inv of current.values()) {
const prev = before.get(inv.code) || 0
if ((inv.uses || 0) > prev && found === empty) {
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
}
}
const next = new Map()
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
cache.set(guild.id, next)
return found
} catch (err) {
log.warn('invite attribution failed', { message: err.message })
return empty
}
}
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }

View File

@@ -8,6 +8,8 @@ const { findMatch } = require('../filter/normalize')
const inviteFilter = require('../filter/inviteFilter')
const spamFilter = require('../filter/spamFilter')
const warnings = require('../model/warnings')
const filterHits = require('../model/filterHits')
const spamHits = require('../model/spamHits')
const modLog = require('./modLog')
const createLogger = require('../utils/logger')
@@ -19,6 +21,48 @@ function botActor(client) {
return { id: client.user.id, tag: client.user.tag }
}
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
// break the moderation action it accompanies, so failures are swallowed+logged.
async function recordFilterHit(message, hitType, matched, actionTaken) {
try {
await filterHits.record({
guildId: message.guildId,
hitType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
matched,
actionTaken,
})
} catch (err) {
log.warn('filter hit record failed', { message: err.message })
}
}
async function recordSpamHit(message, spamType) {
try {
await spamHits.record({
guildId: message.guildId,
spamType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
})
} catch (err) {
log.warn('spam hit record failed', { message: err.message })
}
}
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
// effect (records this message's timestamp) so it must be evaluated first, and
// exactly once — mirroring the original OR-order.
function detectSpam(message) {
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
if (spamFilter.isMassMention(message)) return 'mass_mention'
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
return null
}
async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null
@@ -62,8 +106,10 @@ async function handleMessageCreate(message) {
const cache = await filterCache.getOrLoad(message.guildId)
if (await isBypassed(message, cache)) return
if (await inviteFilter.containsForeignInvite(message)) {
const foreignCode = await inviteFilter.foreignInviteCode(message)
if (foreignCode) {
await message.delete().catch(() => {})
await recordFilterHit(message, 'invite', foreignCode, 'warn')
await applyWarnAction(message, 'Posted a Discord invite link')
return
}
@@ -71,17 +117,16 @@ async function handleMessageCreate(message) {
const match = findMatch(message.content, cache.words)
if (match) {
await message.delete().catch(() => {})
await recordFilterHit(message, 'word', match.word, match.severity)
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
return
}
if (
spamFilter.isRateLimited(message.guildId, message.author.id) ||
spamFilter.isMassMention(message) ||
spamFilter.isMassEmoji(message.content)
) {
const spamType = detectSpam(message)
if (spamType) {
await message.delete().catch(() => {})
await recordSpamHit(message, spamType)
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
}
} catch (err) {

View File

@@ -4,20 +4,23 @@
// than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
async function containsForeignInvite(message) {
// Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather
// than a bare boolean) lets the caller record which invite was blocked.
async function foreignInviteCode(message) {
const matches = [...message.content.matchAll(INVITE_REGEX)]
if (matches.length === 0) return false
if (matches.length === 0) return null
for (const match of matches) {
const code = match[1]
try {
const invite = await message.client.fetchInvite(code)
if (invite.guild?.id !== message.guildId) return true
if (invite.guild?.id !== message.guildId) return code
} catch {
return true
return code
}
}
return false
return null
}
module.exports = { containsForeignInvite }
module.exports = { foreignInviteCode }

View File

@@ -0,0 +1,15 @@
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
// word filter or foreign-invite filter deletes a message. mod_actions still
// records the resulting warn/mute separately. Schema: server/db/schema.sql
// (filter_hits).
const db = require('../db')
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
await db.query(
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
)
}
module.exports = { record }

View File

@@ -0,0 +1,14 @@
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
// the moderation dashboard's members feed + invite-usage view. Schema in
// server/db/schema.sql (member_events).
const db = require('../db')
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
await db.query(
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
)
}
module.exports = { record }

14
bot/src/model/spamHits.js Normal file
View File

@@ -0,0 +1,14 @@
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
const db = require('../db')
async function record({ guildId, spamType, discordUserId, username, channelId }) {
await db.query(
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
VALUES (?, ?, ?, ?, ?)`,
[guildId, spamType, discordUserId, username || null, channelId || null],
)
}
module.exports = { record }

View File

@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RoleGate from './components/RoleGate.jsx'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -31,6 +32,8 @@ import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
export default function App() {
return (
@@ -73,6 +76,17 @@ export default function App() {
<Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} />
<Route path="settings" element={<SettingsAdmin />} />
<Route
path="moderation"
element={
<RoleGate roles={['admin', 'moderator']}>
<Outlet />
</RoleGate>
}
>
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} />

View File

@@ -46,6 +46,9 @@ export const api = {
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),
@@ -117,6 +120,52 @@ export const api = {
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
},
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
modMembers: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
},
modFilterHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
},
modSpamHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
},
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
},
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
addModNote: (discordId, data) =>
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),

View File

@@ -0,0 +1,11 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
// Client-side role gate for admin sub-sections. Real enforcement is server-side
// (requireRole); this just keeps the UI honest — a user without one of `roles`
// is redirected rather than shown a page that will only 403 on every call.
export default function RoleGate({ roles, children, redirect = '/admin' }) {
const { user } = useAuth()
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
return children
}

View File

@@ -37,6 +37,14 @@ export function AuthProvider({ children }) {
return data.user
}, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
const ssoLoginTotp = useCallback(async (code) => {
const data = await api.ssoLoginTotp(code)
setUser(data.user)
return data
}, [])
const logout = useCallback(async () => {
try {
await api.logout()
@@ -46,7 +54,7 @@ export function AuthProvider({ children }) {
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, loginTotp, logout, refresh }}>
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
{children}
</AuthContext.Provider>
)

View File

@@ -4,11 +4,15 @@ import MoonDot from '../../components/MoonDot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
// `roles` (when present) restricts which roles see a nav item. Items without it
// are shown to admin/editor as before. Moderators are further confined to just
// their own section + account security (see the redirect effect below).
const NAV = [
{ to: '/admin', label: 'Dashboard', end: true },
{ to: '/admin/posts', label: 'Posts' },
{ to: '/admin/wiki', label: 'Wiki' },
{ to: '/admin/hero', label: 'Hero Editor' },
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
{ to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' },
{ to: '/admin/bot-activity', label: 'Bot Activity' },
@@ -23,6 +27,7 @@ const TITLES = {
'/admin/posts': 'Posts',
'/admin/wiki': 'Wiki Pages',
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Bot Activity',
@@ -48,11 +53,31 @@ export default function AdminLayout() {
const { mode } = useSite()
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || 'Admin'
const title =
TITLES[location.pathname] ||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Moderators only get the moderation section + their own account security.
const isModerator = user?.role === 'moderator'
const navItems = NAV.filter((n) => {
if (n.roles && !n.roles.includes(user?.role)) return false
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
return true
})
// Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => {
if (!isModerator) return
const p = location.pathname
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
navigate('/admin/moderation', { replace: true })
}
}, [isModerator, location.pathname, navigate])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => {
const meta = document.createElement('meta')
@@ -94,7 +119,7 @@ export default function AdminLayout() {
</div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{NAV.map((n) => (
{navItems.map((n) => (
<NavLink
key={n.to}
to={n.to}

View File

@@ -31,7 +31,7 @@ const honeypotStyle = {
}
export default function AdminLogin() {
const { user, login, loginTotp } = useAuth()
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const dest = location.state?.from?.pathname || '/admin'
@@ -42,10 +42,12 @@ export default function AdminLogin() {
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Two-factor step state.
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false)
// SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...).
@@ -57,6 +59,16 @@ export default function AdminLogin() {
if (user) navigate(dest, { replace: true })
}, [user, dest, navigate])
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
// before it will issue a session. Jump straight to the code step.
useEffect(() => {
if (new URLSearchParams(location.search).get('sso_totp')) {
setStage('totp')
setSsoTotp(true)
}
}, [location.search])
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
// still works with password login and simply shows no provider buttons.
useEffect(() => {
@@ -101,16 +113,25 @@ export default function AdminLogin() {
setError('')
setBusy(true)
try {
await loginTotp(challenge, code)
navigate(dest, { replace: true })
if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code)
navigate(returnTo || '/admin', { replace: true })
} else {
await loginTotp(challenge, code)
navigate(dest, { replace: true })
}
} catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message)
setError(
err.status === 401 && /expired/i.test(err.message)
expired
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
setBusy(false)
if (err.status === 401 && /expired/i.test(err.message)) setStage('creds')
if (expired) {
setStage('creds')
setSsoTotp(false)
}
}
}

View File

@@ -0,0 +1,330 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
const WINDOWS = [
{ key: '24h', label: 'Last 24h' },
{ key: '7d', label: 'Last 7 days' },
{ key: '30d', label: 'Last 30 days' },
]
const TYPES = [
{ key: null, label: 'All' },
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
const MOD_TILES = [
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
// Second tile row → jumps the events panel to the matching stream.
const EVENT_TILES = [
{ key: 'joins', label: 'Joins', tab: 'members' },
{ key: 'leaves', label: 'Leaves', tab: 'members' },
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
]
const EVENT_TABS = [
{ key: 'members', label: 'Members' },
{ key: 'filter', label: 'Filter hits' },
{ key: 'spam', label: 'Spam hits' },
]
export default function Moderation() {
const navigate = useNavigate()
const [win, setWin] = useState('24h')
const [typeFilter, setTypeFilter] = useState(null)
const [eventTab, setEventTab] = useState('members')
const { loading, error, data } = useAsync(
() =>
Promise.all([
api.admin.modSummary(),
api.admin.modRecent({ limit: 100 }),
api.admin.modMembers({ limit: 50 }),
api.admin.modFilterHits({ limit: 50 }),
api.admin.modSpamHits({ limit: 50 }),
]),
[],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load moderation data." />
const [summary, recent, members, filterHits, spamHits] = data
const counts = summary.windows?.[win] || {}
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
return (
<section>
<UserSearch onPick={goUser} />
{/* Window selector */}
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
{WINDOWS.map((w) => (
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
{w.label}
</button>
))}
</div>
{/* Moderation-action tiles (click filters the recent-actions feed) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
{MOD_TILES.map((t) => (
<Tile
key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
active={typeFilter === t.key}
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
/>
))}
</div>
{/* Event tiles (click jumps the events panel to that stream) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
{EVENT_TILES.map((t) => (
<Tile
key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
active={eventTab === t.tab}
onClick={() => setEventTab(t.tab)}
/>
))}
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
</p>
{/* Recent moderation actions */}
<div style={rowHead}>
<h2 className="display" style={h2}>Recent actions</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{TYPES.map((t) => (
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
{t.label}
</button>
))}
</div>
</div>
<div className="panel-flat" style={{ marginBottom: 30 }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">Staff</th>
<th className="adm-th">Reason</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{feed.length === 0 && (
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
)}
{feed.map((a) => (
<tr key={a.id}>
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
<td className="adm-td">
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
</td>
<td className="adm-td">
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
</td>
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Event streams panel */}
<div style={rowHead}>
<h2 className="display" style={h2}>Events</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{EVENT_TABS.map((t) => (
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
{t.label}
</button>
))}
</div>
</div>
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
</section>
)
}
function Tile({ value, label, sub, active, onClick }) {
return (
<button
onClick={onClick}
style={{
textAlign: 'left',
padding: 20,
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
borderRadius: 12,
background: 'var(--panel-grad)',
cursor: 'pointer',
}}
>
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
</button>
)
}
function MembersTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Event</th>
<th className="adm-th">User</th>
<th className="adm-th">Invite</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
{rows.map((m) => (
<tr key={m.id}>
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
<td className="adm-td dim">
{m.invite_code ? (
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
) : '—'}
</td>
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function FilterTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Type</th>
<th className="adm-th">User</th>
<th className="adm-th">Matched</th>
<th className="adm-th">Action</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
{rows.map((f) => (
<tr key={f.id}>
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
function SpamTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Type</th>
<th className="adm-th">User</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
{rows.map((s) => (
<tr key={s.id}>
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// User lookup: search by Discord id or a historical username snapshot.
function UserSearch({ onPick }) {
const [term, setTerm] = useState('')
const [results, setResults] = useState(null)
const [busy, setBusy] = useState(false)
async function run(e) {
e.preventDefault()
const q = term.trim()
if (!q) return
setBusy(true)
try {
setResults(await api.admin.modSearch(q))
} finally {
setBusy(false)
}
}
return (
<div style={{ marginBottom: 22 }}>
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
</form>
{results && results.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match {term}.</p>
)}
{results && results.length > 0 && (
<div className="panel-flat" style={{ marginTop: 10 }}>
<table className="adm-table">
<tbody>
{results.map((r) => (
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
<td className="adm-td dim">last {ago(r.last_seen)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
const muted = { color: 'var(--muted)' }

View File

@@ -0,0 +1,245 @@
import { useCallback, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
const ACTION_TABS = [
{ key: 'warn', label: 'Warnings' },
{ key: 'mute', label: 'Mutes' },
{ key: 'kick', label: 'Kicks' },
{ key: 'ban', label: 'Bans' },
]
function fmtDuration(seconds) {
if (!seconds) return null
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
export default function ModerationUser() {
const { discordId } = useParams()
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const [tab, setTab] = useState('warn')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() =>
Promise.all([
api.admin.modUser(discordId),
api.admin.modUserActions(discordId, { limit: 200 }),
api.admin.modUserNotes(discordId),
]),
[discordId, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this users history." />
const [summary, actions, notes] = data
const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab)
return (
<section>
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to moderation
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{summary.tag || '(unknown user)'}
</span>
{summary.linked_account && (
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
)}
</div>
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
{discordId}
</div>
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
{ACTION_TABS.map((t) => (
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
))}
<Count label="Notes" value={summary.notes_count || 0} />
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
{ACTION_TABS.map((t) => (
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label} ({counts[t.key] || 0})
</TabButton>
))}
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
Notes ({summary.notes_count || 0})
</TabButton>
</div>
{tab === 'notes' ? (
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
</section>
)
}
function Count({ label, value }) {
return (
<div>
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
</div>
)
}
function TabButton({ active, onClick, children }) {
return (
<button
onClick={onClick}
className="sans"
style={{
border: '1px solid var(--line)',
borderRadius: 8,
padding: '7px 14px',
cursor: 'pointer',
fontSize: '0.85rem',
background: active ? 'var(--blue)' : 'transparent',
color: active ? 'var(--ink)' : 'var(--muted)',
borderColor: active ? 'var(--accent)' : 'var(--line)',
}}
>
{children}
</button>
)
}
function ActionTable({ rows, showDuration }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reason</th>
<th className="adm-th">Actor</th>
{showDuration && <th className="adm-th">Duration</th>}
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
Nothing here.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</td>
<td className="adm-td">
{a.is_automated ? (
<span className="badge badge-auto">Automated</span>
) : (
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
)}
</td>
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('staff_only')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function add() {
if (!body.trim()) return
setBusy(true)
setErr('')
try {
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
setBody('')
setVisibility('staff_only')
onAdded()
} catch (e) {
setErr(e.message || 'Could not save the note.')
} finally {
setBusy(false)
}
}
return (
<div>
<div style={{ marginBottom: 18 }}>
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
<textarea
className="textarea"
placeholder="Add a staff note about this user…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
<option value="staff_only">Staff only</option>
{isAdmin && <option value="admin_only">Admin only</option>}
</select>
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Add note'}
</button>
</div>
</div>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Note</th>
<th className="adm-th">Author</th>
<th className="adm-th">Visibility</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{notes.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
</tr>
)}
{notes.map((n) => (
<tr key={n.id}>
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
<td className="adm-td">
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
</span>
</td>
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) {
<select value={form.role} onChange={set('role')} className="select">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="moderator">moderator</option>
</select>
</label>
</div>

View File

@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import UserEditor from './UserEditor.jsx'
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
export default function UsersAdmin() {
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Manage admin and editor accounts
Manage admin, editor, and moderator accounts
</p>
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
+ Add user
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
{u.username}
</td>
<td className="adm-td">
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
</td>
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>

View File

@@ -613,6 +613,29 @@ button[disabled] {
color: var(--muted);
border: 1px solid var(--line);
}
.badge-moderator {
background: rgba(224, 176, 112, 0.12);
color: #e0b070;
border: 1px solid rgba(224, 176, 112, 0.4);
}
/* Action-type badges for the moderation dashboard. */
.badge-ban {
background: rgba(217, 139, 132, 0.16);
color: #d98b84;
border: 1px solid rgba(217, 139, 132, 0.4);
}
.badge-kick,
.badge-mute,
.badge-warn {
background: rgba(224, 176, 112, 0.12);
color: #e0b070;
border: 1px solid rgba(224, 176, 112, 0.4);
}
.badge-auto {
background: rgba(127, 153, 189, 0.14);
color: #9fb0c6;
border: 1px solid var(--line);
}
.link-accent {
color: var(--accent);
text-decoration: none;

View File

@@ -35,6 +35,10 @@ services:
- uploads:/app/uploads
# Bind-mount logs to the host so app.log is directly readable at ./logs/
- ./logs:/app/logs
# Only the PUBLIC API port (3000) is published. The internal server<->bot
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
# stays reachable only over the private compose network — Pangolin/the public
# reverse proxy can never forward to it. See issue #33.
# Binds 0.0.0.0 (no 127.0.0.1 prefix) so Pangolin can reach the container.
ports:
- "3000:3000"
@@ -47,7 +51,17 @@ services:
env_file: .env
environment:
DB_HOST: db
SITE_INTERNAL_URL: http://app:3000/api/v1/internal/bot-config
# Pin the bot's own listen port. Both services share env_file: .env, so
# without this the site's PORT=3000 leaks in and the bot binds 3000 instead
# of 4100 — then the server's BOT_INTERNAL_URL (http://bot:4100) can't reach
# it ("failed to fetch" in the admin panel). Must match that URL's port.
PORT: 4100
# Likewise override the log filename so the bot doesn't inherit the site's
# LOG_FILE and write into app.log — keep the bot's log distinct.
LOG_FILE: bot.log
# Internal config fetch goes to the app's UNPUBLISHED internal port (3001),
# not the public 3000. Keep the port in sync with the app's INTERNAL_PORT.
SITE_INTERNAL_URL: http://app:3001/internal/bot-config
SITE_PUBLIC_URL: http://app:3000/api/v1/public
LOG_DIR: /app/bot/logs
depends_on:

View File

@@ -4,6 +4,10 @@
NODE_ENV=development
PORT=3000
# Separate, unpublished port for server<->bot internal traffic (the decrypted
# bot-token route). Must match the port in bot/.env's SITE_INTERNAL_URL and must
# never be exposed through a public reverse proxy. See issue #33.
INTERNAL_PORT=3001
# Logging — written to BOTH the console and a log file (default <server>/logs/app.log).
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
FILE_LOG_LEVEL=debug # file verbosity
@@ -77,8 +81,11 @@ CLIENT_ORIGIN=http://localhost:5173
# Discord bot — internal API (server <-> bot/). BOT_INTERNAL_KEY MUST be
# byte-for-byte identical to the same variable in bot/.env.example — it is the
# only auth on both sides' /internal/* routes, so a mismatch silently breaks
# every server<->bot call with 401s. The Discord bot TOKEN itself is not an env
# var — it's entered in the admin panel (Discord Bot page) and stored
# every server<->bot call with 401s. It also guards the server's
# /internal/bot-config route, which returns the DECRYPTED Discord token: with
# NODE_ENV=production the app REFUSES TO START if this is blank, a documented
# placeholder, or shorter than 16 chars (a warning only in dev). The Discord bot
# TOKEN itself is not an env var — it's entered in the admin panel and stored
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
BOT_INTERNAL_URL=http://localhost:4100
BOT_INTERNAL_KEY=dev-only-change-me-bot-key

View File

@@ -6,9 +6,12 @@ CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(72) NOT NULL,
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin',
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
-- Any session token issued before this instant is rejected (see requireAuth).
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
tokens_valid_after DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -176,6 +179,22 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
INDEX idx_mrt_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted
-- per session in createSession. A single logout adds this session's jti here;
-- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at
-- mirrors the token's own exp, after which the JWT fails verification anyway, so
-- the row is dead weight and gets pruned. "Log out everywhere" / password change
-- do NOT use this table — they bump users.tokens_valid_after instead (one row vs.
-- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens.
CREATE TABLE IF NOT EXISTS revoked_sessions (
jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4)
user_id INT NULL,
expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after)
revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_revoked_sessions_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's
-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth
-- client secrets are, and is only ever decrypted server-side to push to the
@@ -349,6 +368,83 @@ CREATE TABLE IF NOT EXISTS invite_log (
INDEX idx_invite_log_guild (guild_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves
-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/
-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_*
-- record which invite was used when the bot could attribute it (best-effort, see
-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves.
-- These are member lifecycle events, not moderation actions, hence separate from
-- mod_actions.
CREATE TABLE IF NOT EXISTS member_events (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
event_type ENUM('join','leave') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
invite_code VARCHAR(20) NULL,
inviter_id VARCHAR(32) NULL,
inviter_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_events_guild (guild_id, created_at),
INDEX idx_member_events_user (guild_id, discord_user_id, created_at),
INDEX idx_member_events_invite (guild_id, invite_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated content-filter hits (Phase 6b): one row per message the word filter
-- or the foreign-invite filter deleted. Separate from mod_actions (which still
-- records the resulting warn/mute) so the dashboard can show filter volume in
-- its own right. `matched` holds the offending word (word hits) or the blocked
-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned
-- (bot/src/discord/messageFilter.js).
CREATE TABLE IF NOT EXISTS filter_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
hit_type ENUM('word','invite') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
matched VARCHAR(200) NULL,
action_taken ENUM('delete','warn','mute') NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filter_hits_guild (guild_id, created_at),
INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention /
-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting
-- warn; this records the detection itself for the dashboard's spam feed.
-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js).
CREATE TABLE IF NOT EXISTS spam_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_spam_hits_guild (guild_id, created_at),
INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
-- and read only by the main site (moderation.controller), never by the bot.
-- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so
-- notes attach to a Discord identity even when it has no linked site account.
-- Notes are never user-visible; admin_only notes are further restricted to the
-- admin role (moderators see staff_only only) — enforced in the query layer.
CREATE TABLE IF NOT EXISTS mod_notes (
id INT AUTO_INCREMENT PRIMARY KEY,
discord_user_id VARCHAR(32) NOT NULL,
author_user_id INT NULL,
author_tag VARCHAR(120) NULL,
body TEXT NOT NULL,
visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_mod_notes_user (discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
@@ -357,6 +453,12 @@ CREATE TABLE IF NOT EXISTS invite_log (
-- Opt-in TOTP two-factor columns for databases created before login hardening.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
-- Session-revocation cutoff for databases created before token revocation landed.
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
-- an idempotent no-op, so it is safe to run on every boot.
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin';
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;

View File

@@ -15,6 +15,18 @@ const sessionService = require('./session.service')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// True if this session was issued at or before the user's tokens_valid_after
// cutoff (i.e. revoked by a password change / log-out-everywhere). Both the JWT
// iat and the cutoff are second-granular, so the comparison is inclusive: a token
// minted in the same second as the bump must still be revoked (otherwise it would
// survive its full lifetime through that 1s alignment). The only cost is that a
// re-login within the same second as the change is rejected until the next second
// — a self-healing blip, and far preferable to leaving a stale token valid.
function isBeforeCutoff(session, tokensValidAfter) {
if (!tokensValidAfter || session.createdAt == null) return false
return session.createdAt <= new Date(tokensValidAfter).getTime()
}
// Best-effort: if the request carries a valid session token, attach the decoded
// session (no DB hit), its auth method, and request metadata. Never rejects —
// anonymous requests simply pass through with req.session undefined.
@@ -38,6 +50,18 @@ async function requireAuth(req, res, next) {
try {
const user = await users.getById(session.userId)
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
// Revocation, enforced here (not in stateless token verification):
// 1. per-user cutoff — password change / "log out everywhere" bumps
// tokens_valid_after; any token issued before it is dead.
// 2. per-session denylist — a single logout adds this jti to revoked_sessions.
if (isBeforeCutoff(session, user.tokens_valid_after)) {
return res.status(401).json({ message: 'Unauthorized' })
}
if (await sessionService.isSessionRevoked(session.sessionId)) {
return res.status(401).json({ message: 'Unauthorized' })
}
req.user = user
req.session = session
req.authMethod = session.authMethod

View File

@@ -14,16 +14,20 @@
// role,
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
// createdAt, // ms epoch the token was issued (JWT iat)
// expiresAt, // ms epoch the token expires (JWT exp), or null
// lastSeenAt, // ms epoch this session was last validated
// }
//
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
// server-side session store yet — these are documented hook points for a future
// store (e.g. a denylist of jti, or mobile refresh-token records).
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
// denylist (revoked_sessions — single logout) and a per-user cutoff
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
// consults both. The functions here are the seam the controllers call.
const crypto = require('crypto')
const token = require('./token')
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
@@ -32,10 +36,24 @@ const log = require('../utils/logger')('session')
// authenticated without changing this module per provider.
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
// The claim that positively marks a token as a real, full session. Every JWT in
// the app is signed with the same secret and is distinguished only by claims, so
// a session must be identified by what it *is* (typ === 'session'), never by the
// mere absence of some other marker. Only the session-minting paths below stamp
// it; flow/challenge tokens (the TOTP challenge, the SSO transaction cookie) do
// not, so — even though they verify against the same secret — they can never be
// mistaken for a session. See issue #32 (sso_tx token-type confusion).
const SESSION_TYP = 'session'
// Build a Session object from a decoded JWT payload. Returns null for anything
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
// that is not a full session. Validation is positively typed: a token qualifies
// only if it was explicitly minted as a session. As belt-and-suspenders we also
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
// SSO transaction), so a future minting path that forgets to omit those still
// can't produce an accepted session.
function sessionFromDecoded(decoded, now = Date.now()) {
if (!decoded || decoded.stage) return null
if (!decoded || decoded.typ !== SESSION_TYP) return null
if (decoded.stage || decoded.kind) return null
return {
sessionId: decoded.jti || null,
userId: decoded.id,
@@ -43,6 +61,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
role: decoded.role,
authMethod: decoded.authMethod || 'local',
createdAt: decoded.iat ? decoded.iat * 1000 : null,
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
lastSeenAt: now,
}
}
@@ -56,7 +75,7 @@ function sessionFromDecoded(decoded, now = Date.now()) {
function createSession(user, authMethod = 'local') {
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
const sessionId = crypto.randomUUID()
const raw = token.signToken(user, { authMethod: method, jti: sessionId })
const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
const session = sessionFromDecoded(token.verifyToken(raw))
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
return { token: raw, session }
@@ -124,7 +143,7 @@ function mintMobileTokens(user, meta = {}, now = Date.now()) {
const sessionId = crypto.randomUUID()
const accessToken = token.signToken(
user,
{ authMethod: 'mobile', jti: sessionId },
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
{ expiresIn: MOBILE_ACCESS_TTL },
)
// 256 bits of entropy, url-safe. Opaque — carries no claims.
@@ -179,23 +198,52 @@ function sessionMeta(req) {
return { ip, userAgent, deviceHash }
}
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
// JWTs are stateless: there is no store to revoke against yet. These are the
// hook points a future session store (jti denylist, mobile refresh records)
// will implement. They log and report success so callers can wire them in now.
// ── Revocation / invalidation ──────────────────────────────────────────────
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
// two server-side stores these functions write:
// • revoked_sessions — a per-session jti denylist (single logout)
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
// A jti + its expiry (from the decoded token) are needed to denylist one session;
// invalidating all of a user's sessions only needs their id.
function revokeSession(sessionId) {
log.info('revokeSession (stub — no session store yet)', { sessionId })
// Revoke a single session by its jti. Needs the token's expiry so the denylist
// row can self-prune once the JWT would fail verification anyway. Idempotent.
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
if (!sessionId) {
log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
return false
}
// Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
// so the denylist row still outlives any token carrying this jti.
const exp = expiresAt || Date.now() + token.cookieMaxAge()
await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
log.info('session revoked', { sessionId, userId })
return true
}
function invalidateSession(sessionId) {
log.info('invalidateSession (stub — no session store yet)', { sessionId })
return true
// Alias kept for callers that speak of "invalidating" one session.
async function invalidateSession(sessionId, opts) {
return revokeSession(sessionId, opts)
}
function invalidateAllUserSessions(userId) {
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
// Has this session (jti) been individually revoked? Used by requireAuth on every
// authenticated request. Broad "valid after" cutoffs are checked separately by
// the middleware against the fresh user row it already loads.
async function isSessionRevoked(sessionId) {
if (!sessionId) return false
return revokedSessions.isRevoked(sessionId)
}
// Invalidate every session a user holds (password change / log out everywhere)
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
// before now regardless of jti.
async function invalidateAllUserSessions(userId) {
if (!userId) {
log.warn('invalidateAllUserSessions called without a userId')
return false
}
await users.invalidateSessions(userId)
log.info('all user sessions invalidated', { userId })
return true
}
@@ -209,6 +257,7 @@ module.exports = {
sessionMeta,
revokeSession,
invalidateSession,
isSessionRevoked,
invalidateAllUserSessions,
// Mobile bearer sessions.
createMobileSession,

View File

@@ -19,6 +19,14 @@ const token = require('./token')
const TX_COOKIE = 'sso_tx'
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
// Second leg of an SSO login for an account that has TOTP enabled. The callback
// authenticated the user with the IdP but must NOT bypass their second factor
// (see issue #31), so instead of minting a session it stages this signed,
// httpOnly cookie and routes the browser through the TOTP form — mirroring the
// local password→TOTP gate. TTL matches the local challenge window.
const TOTP_COOKIE = 'sso_totp'
const TOTP_TTL = '5m'
// base64url of random bytes — used for the nonce and the PKCE verifier.
function randomUrlSafe(bytes = 32) {
return crypto.randomBytes(bytes).toString('base64url')
@@ -56,4 +64,38 @@ function verifyTx(txToken, stateNonce) {
return decoded
}
module.exports = { TX_COOKIE, TX_TTL, createTx, verifyTx, codeChallengeFor, randomUrlSafe }
// Stage the pending second factor for an SSO login. Carries the context the
// callback already resolved (userId, provider, authMethod, returnTo) so that
// presenting a valid code alone finishes the login. It is deliberately NOT a
// session: `stage: 'totp'` makes session validation reject it (same marker the
// local TOTP challenge uses), and `kind: 'sso_totp'` both reinforces that and
// scopes it to the SSO completion endpoint.
function createTotpPending({ userId, provider, authMethod, returnTo }) {
return token.signToken(
{ id: userId }, // subject only; identity is re-loaded fresh when the code is verified
{ stage: 'totp', kind: 'sso_totp', provider, authMethod, returnTo },
{ expiresIn: TOTP_TTL },
)
}
// Verify a pending-TOTP cookie. Returns the payload
// ({ id, provider, authMethod, returnTo, ... }) or null if missing/expired/wrong-kind.
function verifyTotpPending(pendingToken) {
if (!pendingToken) return null
const decoded = token.verifyToken(pendingToken)
if (!decoded || decoded.stage !== 'totp' || decoded.kind !== 'sso_totp') return null
return decoded
}
module.exports = {
TX_COOKIE,
TX_TTL,
TOTP_COOKIE,
TOTP_TTL,
createTx,
verifyTx,
createTotpPending,
verifyTotpPending,
codeChallengeFor,
randomUrlSafe,
}

26
server/src/internalApp.js Normal file
View File

@@ -0,0 +1,26 @@
// Standalone Express app for server<->bot internal traffic. It is mounted on its
// OWN http listener (INTERNAL_PORT, default 3001) in server.js — an unpublished,
// compose-network-only port, mirroring how the bot exposes its internal API on
// 4100. Crucially it is NOT part of the public API app (app.js), so /internal/*
// (which returns the DECRYPTED Discord bot token) can never ride the same
// listener Pangolin proxies to the world. Shared-secret gated by
// requireInternalKey inside internal.routes. See issue #33.
const express = require('express')
const internalRouter = require('./router/v1/internal/internal.routes')
const internalApp = express()
internalApp.use(express.json())
// Liveness probe for this listener (no secret required); mirrors the bot's
// /health on 4100. Useful for compose healthchecks without exposing anything.
internalApp.get('/health', (req, res) => res.json({ status: 'ok' }))
// requireInternalKey is applied inside internal.routes.
internalApp.use('/internal', internalRouter)
// Anything else on this listener is not a real internal route.
internalApp.use((req, res) => res.status(404).json({ message: 'Not found' }))
module.exports = internalApp

View File

@@ -0,0 +1,51 @@
// Staff notes on a Discord user (server-owned, see db/schema.sql mod_notes).
// Notes are never user-visible; admin_only notes are filtered out for non-admin
// callers at this layer via includeAdminOnly.
const { query } = require('../../utils/db')
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
const visClause = includeAdminOnly ? '' : "AND n.visibility = 'staff_only'"
return query(
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
n.body, n.visibility, n.created_at,
u.username AS author_username
FROM mod_notes n
LEFT JOIN users u ON u.id = n.author_user_id
WHERE n.discord_user_id = ? ${visClause}
ORDER BY n.id DESC`,
[discordId],
)
}
async function insert({ discordUserId, authorUserId = null, authorTag = null, body, visibility = 'staff_only' }) {
const res = await query(
`INSERT INTO mod_notes (discord_user_id, author_user_id, author_tag, body, visibility)
VALUES (?, ?, ?, ?, ?)`,
[discordUserId, authorUserId, authorTag, body, visibility],
)
return res.insertId
}
async function getById(id) {
const rows = await query(
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
n.body, n.visibility, n.created_at,
u.username AS author_username
FROM mod_notes n
LEFT JOIN users u ON u.id = n.author_user_id
WHERE n.id = ? LIMIT 1`,
[id],
)
return rows[0] || null
}
async function countForUser(discordId, { includeAdminOnly = false } = {}) {
const visClause = includeAdminOnly ? '' : "AND visibility = 'staff_only'"
const rows = await query(
`SELECT COUNT(*) AS c FROM mod_notes WHERE discord_user_id = ? ${visClause}`,
[discordId],
)
return Number(rows[0].c)
}
module.exports = { listForUser, insert, getById, countForUser }

View File

@@ -0,0 +1,18 @@
const modNotesDb = require('./modNotes.db')
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
return modNotesDb.listForUser(discordId, { includeAdminOnly })
}
async function add({ discordUserId, author, body, visibility = 'staff_only' }) {
const id = await modNotesDb.insert({
discordUserId,
authorUserId: author ? author.id : null,
authorTag: author ? author.username : null,
body,
visibility,
})
return modNotesDb.getById(id)
}
module.exports = { listForUser, add }

View File

@@ -0,0 +1,188 @@
// Read-only access to the bot-owned moderation tables (mod_actions) for the
// admin moderation dashboard (Phase 6). These tables are normally owned by the
// bot process (bot/src/db.js) — see the comment in db/schema.sql — but they live
// in the same physical database, so the site reads them directly through the
// shared pool rather than round-tripping the bot over the internal API. This
// module NEVER writes them; all writes still belong to the bot.
//
// mod_actions is the single source of truth for ban/kick/mute/warn (every warn
// command also mirrors into `warnings`, so counting mod_actions avoids double
// counting). Accounts are correlated to Discord ids via user_identities
// (provider='discord', subject=<snowflake>), the same link the SSO flow writes.
const { query } = require('../../utils/db')
const TYPES = ['ban', 'kick', 'mute', 'warn']
// Per-type counts across three nested windows in a single scan. Boolean
// comparisons yield 1/0 in MariaDB, so SUM(created_at >= cutoff) counts the
// rows inside each window. Returns raw rows: [{ action_type, d1, d7, d30 }].
async function countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT action_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM mod_actions
WHERE created_at >= ?
GROUP BY action_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
const ACTION_SELECT = `
SELECT ma.id, ma.guild_id, ma.action_type,
ma.target_user_id, ma.target_tag,
ma.staff_user_id, ma.staff_tag,
ma.reason, ma.duration_seconds, ma.created_at,
ui.user_id AS target_site_user_id,
u.username AS target_site_username
FROM mod_actions ma
LEFT JOIN user_identities ui
ON ui.provider = 'discord' AND ui.subject = ma.target_user_id
LEFT JOIN users u ON u.id = ui.user_id`
// Most-recent-first action feed, optionally filtered by type. limit/offset
// pagination matching the activity-log convention.
async function recentActions({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE ma.action_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
}
// Full action history for one Discord user, optionally filtered by type.
async function userActions(discordId, { type = null, limit = 50, offset = 0 } = {}) {
const where = type
? 'WHERE ma.target_user_id = ? AND ma.action_type = ?'
: 'WHERE ma.target_user_id = ?'
const params = type ? [discordId, type, limit, offset] : [discordId, limit, offset]
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
}
// All-time per-type counts for one user.
async function userCounts(discordId) {
return query(
`SELECT action_type, COUNT(*) AS c FROM mod_actions
WHERE target_user_id = ? GROUP BY action_type`,
[discordId],
)
}
// Latest username snapshot the bot recorded for this Discord id (usernames drift).
async function latestTag(discordId) {
const rows = await query(
'SELECT target_tag FROM mod_actions WHERE target_user_id = ? ORDER BY id DESC LIMIT 1',
[discordId],
)
return rows[0] ? rows[0].target_tag : null
}
// Linked site account for a Discord id, if any (via user_identities).
async function linkedAccount(discordId) {
const rows = await query(
`SELECT u.id, u.username, u.role
FROM user_identities ui
JOIN users u ON u.id = ui.user_id
WHERE ui.provider = 'discord' AND ui.subject = ?
LIMIT 1`,
[discordId],
)
return rows[0] || null
}
// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ──
// Join/leave counts per window (grouped by event_type).
async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT event_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM member_events
WHERE created_at >= ?
GROUP BY event_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
// Attributed-invite join counts per window (joins whose invite we identified).
async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM member_events
WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
// Row-count per window for a simple event table. `table` is an internal literal
// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist.
async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM ${table} WHERE created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE event_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(
`SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at
FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
params,
)
}
async function recentFilterHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at
FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function recentSpamHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at
FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
// by prefix, returning the most recently seen distinct targets. Powers the
// dashboard search box (usernames drift, so we search historical snapshots too).
async function searchTargets(term, { limit = 20 } = {}) {
return query(
`SELECT ma.target_user_id, MAX(ma.target_tag) AS target_tag,
COUNT(*) AS action_count, MAX(ma.created_at) AS last_seen
FROM mod_actions ma
WHERE ma.target_user_id = ? OR ma.target_tag LIKE ?
GROUP BY ma.target_user_id
ORDER BY last_seen DESC
LIMIT ?`,
[term, `${term}%`, limit],
)
}
module.exports = {
TYPES,
countsByWindow,
recentActions,
userActions,
userCounts,
latestTag,
linkedAccount,
searchTargets,
// Phase 6b
memberCountsByWindow,
inviteJoinCountsByWindow,
tableCountsByWindow,
recentMemberEvents,
recentFilterHits,
recentSpamHits,
}

View File

@@ -0,0 +1,105 @@
// Business logic for the moderation dashboard: reshapes the raw mod_actions
// reads into the shapes the admin UI consumes, and annotates each action with
// whether it was an automated (bot) action. For a Discord bot the application_id
// IS the bot's user id, and the filter/spam pipeline records automated actions
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
// staff_user_id === bot_config.application_id reliably flags automated actions
// without needing new columns on mod_actions.
const moderationDb = require('./moderation.db')
const botConfigDb = require('../botConfig/botConfig.db')
const { zeroCounts, annotate, reshapeWindows, windowValue } = require('./moderation.pure')
const DAY_MS = 24 * 60 * 60 * 1000
const WINDOW_KEYS = ['24h', '7d', '30d']
async function botApplicationId() {
try {
const cfg = await botConfigDb.get()
return cfg ? cfg.application_id : null
} catch {
return null
}
}
// Counts by type across 24h / 7d / 30d windows for the overview tiles. Covers
// moderation actions (mod_actions) plus the Phase 6b event streams: member
// joins/leaves, attributed invite joins, and filter/spam hits.
async function summary() {
const now = Date.now()
const cutoffs = {
cutoff24h: new Date(now - DAY_MS),
cutoff7d: new Date(now - 7 * DAY_MS),
cutoff30d: new Date(now - 30 * DAY_MS),
}
const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([
moderationDb.countsByWindow(cutoffs),
moderationDb.memberCountsByWindow(cutoffs),
moderationDb.inviteJoinCountsByWindow(cutoffs),
moderationDb.tableCountsByWindow('filter_hits', cutoffs),
moderationDb.tableCountsByWindow('spam_hits', cutoffs),
])
const windows = reshapeWindows(modRows).windows
const joinRow = memberRows.find((r) => r.event_type === 'join')
const leaveRow = memberRows.find((r) => r.event_type === 'leave')
for (const w of WINDOW_KEYS) {
windows[w].joins = windowValue(joinRow, w)
windows[w].leaves = windowValue(leaveRow, w)
windows[w].invite_joins = windowValue(inviteRow, w)
windows[w].filter_hits = windowValue(filterRow, w)
windows[w].spam_hits = windowValue(spamRow, w)
}
return { windows }
}
// Recent event feeds for the overview's secondary panel (Phase 6b).
async function members(opts) {
return moderationDb.recentMemberEvents(opts)
}
async function filterHits(opts) {
return moderationDb.recentFilterHits(opts)
}
async function spamHits(opts) {
return moderationDb.recentSpamHits(opts)
}
async function recent(opts) {
const appId = await botApplicationId()
return annotate(await moderationDb.recentActions(opts), appId)
}
async function userActions(discordId, opts) {
const appId = await botApplicationId()
return annotate(await moderationDb.userActions(discordId, opts), appId)
}
// Header data for the per-user history page: latest known tag, linked site
// account (if any), and all-time counts per action type.
async function userSummary(discordId) {
const [countRows, tag, linked] = await Promise.all([
moderationDb.userCounts(discordId),
moderationDb.latestTag(discordId),
moderationDb.linkedAccount(discordId),
])
const counts = zeroCounts()
let total = 0
for (const row of countRows) {
const c = Number(row.c) || 0
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
total += c
}
return {
discord_user_id: discordId,
tag,
linked_account: linked,
counts,
total_actions: total,
}
}
async function search(term, opts) {
return moderationDb.searchTargets(term, opts)
}
module.exports = { summary, recent, userActions, userSummary, search, members, filterHits, spamHits }

View File

@@ -0,0 +1,47 @@
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
// free of any DB (or other side-effecting) imports so they can be unit-tested
// without opening a database pool. moderation.model re-exports these.
function zeroCounts() {
return { ban: 0, kick: 0, mute: 0, warn: 0 }
}
// Tag each action as automated (staff is the bot) and fold the joined
// user_identities columns into a linked_account object. The string coercion
// matters — snowflakes can arrive as number or string from different columns.
function annotate(rows, appId) {
return rows.map((r) => {
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
return {
...r,
is_automated: isAutomated,
linked_account: r.target_site_user_id
? { id: r.target_site_user_id, username: r.target_site_username }
: null,
}
})
}
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
// shape the dashboard tiles consume, zero-filling any type with no rows.
function reshapeWindows(rows) {
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
for (const row of rows) {
const t = row.action_type
if (windows['24h'][t] === undefined) continue
windows['24h'][t] = Number(row.d1) || 0
windows['7d'][t] = Number(row.d7) || 0
windows['30d'][t] = Number(row.d30) || 0
}
return { windows }
}
// Pull the count for one window key ('24h'|'7d'|'30d') out of a
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
function windowValue(row, key) {
if (!row) return 0
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
return Number(col) || 0
}
module.exports = { zeroCounts, annotate, reshapeWindows, windowValue }

View File

@@ -0,0 +1,40 @@
const { query } = require('../../utils/db')
// SQL for the revoked_sessions denylist. Rows are keyed on a session's JWT `jti`
// and carry the token's own expiry so they can be pruned once the underlying JWT
// would fail verification anyway. This is the web/cookie analogue of
// mobile_refresh_tokens (opaque, DB-stored, revocable).
// Add a jti to the denylist. INSERT IGNORE makes a repeat logout of the same
// session a harmless no-op (the PK already exists). Returns rows changed.
async function add({ jti, userId = null, expiresAt }) {
const res = await query(
`INSERT IGNORE INTO revoked_sessions (jti, user_id, expires_at)
VALUES (?, ?, ?)`,
[jti, userId, new Date(expiresAt)],
)
return Number(res.affectedRows || 0)
}
// True if this jti is on the denylist and not yet past its stored expiry. Past
// expiry the token itself is already invalid, so a lingering row need not match.
async function isRevoked(jti) {
if (!jti) return false
const rows = await query(
'SELECT 1 FROM revoked_sessions WHERE jti = ? AND expires_at > NOW() LIMIT 1',
[jti],
)
return rows.length > 0
}
// Housekeeping: drop rows whose token has already expired. Returns rows removed.
async function pruneExpired() {
const res = await query('DELETE FROM revoked_sessions WHERE expires_at < NOW()')
return Number(res.affectedRows || 0)
}
module.exports = {
add,
isRevoked,
pruneExpired,
}

View File

@@ -0,0 +1,29 @@
// Web/cookie session denylist. Thin logic layer over revokedSessions.db — mirrors
// the users/mobileSessions split (.db = SQL, .model = the API the rest of the app
// calls). A "revoked session" is a single JWT jti added on logout; requireAuth
// checks isRevoked on every authenticated request. Broad invalidation
// ("everywhere" / password change) does NOT live here — it bumps
// users.tokens_valid_after instead.
const db = require('./revokedSessions.db')
// Add a session's jti to the denylist (single-session logout). Idempotent.
async function revoke({ jti, userId, expiresAt }) {
return db.add({ jti, userId, expiresAt })
}
// True if the given jti has been revoked (and its token hasn't expired yet).
async function isRevoked(jti) {
return db.isRevoked(jti)
}
// Drop denylist rows whose token has already expired.
async function pruneExpired() {
return db.pruneExpired()
}
module.exports = {
revoke,
isRevoked,
pruneExpired,
}

View File

@@ -54,6 +54,13 @@ async function touchLastLogin(id) {
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
}
// Move the "tokens valid after" cutoff to now, invalidating every session token
// issued before this instant (password change / log out everywhere). requireAuth
// compares each session's issued-at against this column.
async function bumpTokensValidAfter(id) {
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
}
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
// so a secret is never trusted until the user has confirmed one code.
async function setTotpSecret(id, secret) {
@@ -78,6 +85,7 @@ module.exports = {
countUsers,
countAdmins,
touchLastLogin,
bumpTokensValidAfter,
setTotpSecret,
enableTotp,
disableTotp,

View File

@@ -58,9 +58,18 @@ async function update(id, { username, password, role }) {
if (role !== undefined) fields.role = role
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
await usersDb.updateUser(id, fields)
// A password change must revoke existing sessions ("change password to log
// everyone out"), so bump the cutoff whenever the hash was rotated.
if (password) await usersDb.bumpTokensValidAfter(id)
return getById(id)
}
// Invalidate every session token this user currently holds ("log out everywhere")
// by advancing their tokens_valid_after cutoff to now.
async function invalidateSessions(id) {
return usersDb.bumpTokensValidAfter(id)
}
async function remove(id) {
return usersDb.deleteUser(id)
}
@@ -85,6 +94,7 @@ module.exports = {
validatePassword,
list,
update,
invalidateSessions,
remove,
count,
countAdmins,

View File

@@ -10,6 +10,7 @@ const account = require('./account.controller')
const botActivity = require('./botActivity.controller')
const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const moderation = require('./moderation.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -23,6 +24,11 @@ adminRouter.use(noindex, isLoggedIn)
// management, site mode, and settings are restricted to the admin role.
const adminOnly = requireRole('admin')
// Moderation-dashboard gate. Moderators get the moderation views; admins can do
// everything a moderator can. Sensitive writes (admin_only notes) add an extra
// admin check inside the controller.
const modAccess = requireRole('admin', 'moderator')
// ── Account security (self-service, any logged-in role) ───────────────
// Not behind adminOnly: an editor manages their own 2FA too.
adminRouter.get(
@@ -30,7 +36,7 @@ adminRouter.get(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Get the current account (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.getAccount,
)
@@ -39,7 +45,7 @@ adminRouter.post(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { type: "object", properties: { otpauth_url: { type: "string" }, qr: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.totpSetup,
@@ -50,7 +56,7 @@ adminRouter.post(
// #swagger.summary = 'Enable 2FA by confirming a code'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -64,7 +70,7 @@ adminRouter.post(
// #swagger.summary = 'Disable 2FA by confirming a code'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
@@ -78,7 +84,7 @@ adminRouter.get(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'List linked SSO identities (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { provider: { type: "string" }, email: { type: "string" } } } } } } } */
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.listIdentities,
)
@@ -88,7 +94,7 @@ adminRouter.delete(
// #swagger.summary = 'Unlink an SSO identity (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('provider').matches(/^[a-z0-9-]+$/),
@@ -136,7 +142,7 @@ adminRouter.get(
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Dashboard summary counts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Summary counts (posts, wiki, users, site mode)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.dashboard,
)
@@ -147,7 +153,7 @@ adminRouter.put(
// #swagger.description = 'Switch the site between live and maintenance.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "maintenance" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -257,7 +263,7 @@ adminRouter.delete(
// #swagger.summary = 'Delete a post'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
@@ -318,7 +324,7 @@ adminRouter.delete(
// #swagger.summary = 'Delete a wiki category'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Category id.' }
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
@@ -457,7 +463,7 @@ adminRouter.delete(
// #swagger.summary = 'Delete a wiki page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Wiki page slug.' }
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Deleted (echoes the slug)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedSlug" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.deleteWiki,
@@ -521,7 +527,7 @@ adminRouter.post(
// #swagger.summary = 'Emergency unban an IP (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanRequest" } } } } */
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Unbanned (echoes the ip and whether an entry was cleared)', content: { "application/json": { schema: { $ref: "#/components/schemas/UnbanResult" } } } } */
/* #swagger.responses[400] = { description: 'Invalid IP', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -636,7 +642,7 @@ adminRouter.delete(
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -647,6 +653,91 @@ adminRouter.delete(
authProviders.remove,
)
// ── Moderation dashboard (admin + moderator) ──────────────────────────
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
// sub-path is gated for the moderator role (admins included).
adminRouter.use('/moderation', modAccess)
adminRouter.get(
'/moderation/stats/summary',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getSummary,
)
adminRouter.get(
'/moderation/recent',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getRecent,
)
adminRouter.get(
'/moderation/search',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.search,
)
adminRouter.get(
'/moderation/members',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getMembers,
)
adminRouter.get(
'/moderation/filter-hits',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent automated content-filter hits'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getFilterHits,
)
adminRouter.get(
'/moderation/spam-hits',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent automated spam-detection hits'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getSpamHits,
)
adminRouter.get(
'/moderation/user/:discordId',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
param('discordId').matches(/^[0-9]{1,32}$/),
validate,
moderation.getUser,
)
adminRouter.get(
'/moderation/user/:discordId/actions',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Full moderation action history for a user'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
param('discordId').matches(/^[0-9]{1,32}$/),
validate,
moderation.getUserActions,
)
adminRouter.get(
'/moderation/user/:discordId/notes',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
param('discordId').matches(/^[0-9]{1,32}$/),
validate,
moderation.getUserNotes,
)
adminRouter.post(
'/moderation/user/:discordId/notes',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
param('discordId').matches(/^[0-9]{1,32}$/),
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
body('visibility').optional().isIn(['staff_only', 'admin_only']),
validate,
moderation.addUserNote,
)
// ── User management (admin only) ──────────────────────────────────────
adminRouter.use('/users', adminOnly)
adminRouter.get(
@@ -672,7 +763,7 @@ adminRouter.post(
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor']),
body('role').optional().isIn(['admin', 'editor', 'moderator']),
validate,
ctrl.createUser,
)
@@ -692,7 +783,7 @@ adminRouter.put(
param('id').isInt(),
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
body('password').optional().isString().isLength({ min: 8, max: 64 }),
body('role').optional().isIn(['admin', 'editor']),
body('role').optional().isIn(['admin', 'editor', 'moderator']),
validate,
ctrl.updateUser,
)
@@ -702,7 +793,7 @@ adminRouter.delete(
// #swagger.summary = 'Delete a user (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -0,0 +1,170 @@
// Admin moderation dashboard (Phase 6). Read-only views over the bot's
// mod_actions log plus server-owned staff notes. Mounted behind the
// admin+moderator RBAC gate (see admin.routes.js). The only mutation here is
// adding a staff note; admin_only notes are further restricted to the admin role.
const moderation = require('../../../model/moderation/moderation.model')
const modNotes = require('../../../model/modNotes/modNotes.model')
const modNotesDb = require('../../../model/modNotes/modNotes.db')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('moderation')
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
const MAX_LIMIT = 200
const DEFAULT_LIMIT = 50
// Parse ?limit/&offset the same way the activity log does: numeric, capped.
function pageParams(req) {
const limit = Math.min(Number(req.query.limit) || DEFAULT_LIMIT, MAX_LIMIT)
const offset = Number(req.query.offset) || 0
return { limit, offset }
}
// Optional ?type filter — ignored unless it is a known action type.
function typeParam(req) {
const t = req.query.type
return VALID_TYPES.has(t) ? t : null
}
function isAdmin(req) {
return req.user && req.user.role === 'admin'
}
async function getSummary(req, res) {
try {
return res.json(await moderation.summary())
} catch (err) {
log.error('summary failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getRecent(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.recent({ type: typeParam(req), limit, offset }))
} catch (err) {
log.error('recent failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function search(req, res) {
try {
const term = (req.query.q || '').trim()
if (!term) return res.json([])
return res.json(await moderation.search(term, { limit: 20 }))
} catch (err) {
log.error('search failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Phase 6b event feeds ──────────────────────────────────────────────
const MEMBER_TYPES = new Set(['join', 'leave'])
async function getMembers(req, res) {
try {
const { limit, offset } = pageParams(req)
const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null
return res.json(await moderation.members({ type: t, limit, offset }))
} catch (err) {
log.error('members failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getFilterHits(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.filterHits({ limit, offset }))
} catch (err) {
log.error('filterHits failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getSpamHits(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.spamHits({ limit, offset }))
} catch (err) {
log.error('spamHits failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getUser(req, res) {
try {
const summary = await moderation.userSummary(req.params.discordId)
const notesCount = await modNotesDb.countForUser(req.params.discordId, {
includeAdminOnly: isAdmin(req),
})
return res.json({ ...summary, notes_count: notesCount })
} catch (err) {
log.error('getUser failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getUserActions(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(
await moderation.userActions(req.params.discordId, { type: typeParam(req), limit, offset }),
)
} catch (err) {
log.error('getUserActions failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getUserNotes(req, res) {
try {
const notes = await modNotes.listForUser(req.params.discordId, {
includeAdminOnly: isAdmin(req),
})
return res.json(notes)
} catch (err) {
log.error('getUserNotes failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function addUserNote(req, res) {
try {
const visibility = req.body.visibility === 'admin_only' ? 'admin_only' : 'staff_only'
// admin_only notes can carry sensitive judgement calls — restrict to admins.
if (visibility === 'admin_only' && !isAdmin(req)) {
return res.status(403).json({ message: 'Only admins can add admin-only notes' })
}
const note = await modNotes.add({
discordUserId: req.params.discordId,
author: req.user,
body: req.body.body,
visibility,
})
await activity.log({
req,
action: 'moderation.note.add',
detail: { discordUserId: req.params.discordId, visibility },
})
return res.status(201).json(note)
} catch (err) {
log.error('addUserNote failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getSummary,
getRecent,
search,
getMembers,
getFilterHits,
getSpamHits,
getUser,
getUserActions,
getUserNotes,
addUserNote,
}

View File

@@ -96,8 +96,24 @@ async function loginTotp(req, res) {
}
}
function logout(req, res) {
// Clear the caller's cookie AND revoke this session server-side, so a copy of the
// token (proxy log, shared machine, XSS-exfiltrated cookie) can't keep being used
// after logout. attachSession populated req.session (best-effort) with the jti +
// expiry; if there was no valid session, there's simply nothing to revoke.
async function logout(req, res) {
clearAuthCookie(req, res)
try {
if (req.session?.sessionId) {
await sessionService.revokeSession(req.session.sessionId, {
userId: req.session.userId,
expiresAt: req.session.expiresAt,
})
await activity.log({ req, userId: req.session.userId, action: 'auth.logout' })
}
} catch (err) {
// Never fail the logout on a revocation/logging hiccup — the cookie is cleared.
log.error('logout revoke error', err)
}
return res.json({ message: 'Logged out.' })
}

View File

@@ -3,6 +3,7 @@ const { body } = require('express-validator')
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { attachSession } = require('../../../auth/session.middleware')
const { loginLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
@@ -65,8 +66,11 @@ authRouter.post(
authRouter.post(
'/logout',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Log out (clear the session cookie)'
// #swagger.summary = 'Log out (clear the cookie and revoke this session)'
/* #swagger.responses[200] = { description: 'Logged out', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
// Best-effort attach (never rejects) so the controller can revoke this session's
// jti — logout stays a no-op for an already-anonymous caller.
attachSession,
logout,
)
authRouter.get(

View File

@@ -19,6 +19,10 @@ const registry = require('../../../auth/providers/registry')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const { needsTotp } = require('./auth.controller')
const log = require('../../../utils/logger')('sso')
@@ -56,6 +60,13 @@ function txCookieOptions(req) {
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
}
// httpOnly cookie carrying the staged pending-TOTP token for the second-factor
// step. Same standard options; TTL matches the token so a stale cookie can't
// outlive the challenge it holds.
function totpCookieOptions(req) {
return { ...token.cookieOptions(req), maxAge: 5 * 60 * 1000 }
}
// GET /auth/providers — public discovery. Never touches secrets.
async function listProviders(req, res) {
try {
@@ -148,6 +159,23 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
if (!user) return res.redirect(loginError('not_linked'))
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
// 2FA parity with the local login (auth.controller): if the account has TOTP
// enabled, an SSO sign-in must NOT bypass the second factor. Stage a signed,
// httpOnly challenge and route the browser through the TOTP form instead of
// minting a session here. See issue #31.
if (needsTotp(user)) {
const pending = ssoState.createTotpPending({
userId: user.id,
provider: providerId,
authMethod,
returnTo: sanitizeReturn(tx.returnTo) || undefined,
})
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
return res.redirect('/admin/login?sso_totp=1')
}
const { token: sessionToken } = sessionService.createSession(user, authMethod)
token.setAuthCookie(req, res, sessionToken)
await users.recordLogin(user.id)
@@ -156,6 +184,44 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin')
}
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
// Reads the staged pending-TOTP cookie, verifies the authenticator code, then
// mints the full session. Mirrors auth.controller.loginTotp: a wrong code is a
// failed attempt (backoff + bot score), and the response is JSON (the login page
// completes this step over fetch and then navigates to returnTo).
async function finishSsoTotp(req, res) {
const pending = ssoState.verifyTotpPending(req.cookies && req.cookies[ssoState.TOTP_COOKIE])
if (!pending) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
}
try {
const user = await users.getRawById(pending.id)
if (!user || !user.totp_enabled || !totp.verifyCode(user.totp_secret, req.body.code)) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('sso TOTP verify failed', { id: pending.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' })
}
// Second factor satisfied — clear the staged cookie and issue the real session.
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
loginProtection.recordSuccess(req.ip)
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
const { token: sessionToken } = sessionService.createSession(user, authMethod)
token.setAuthCookie(req, res, sessionToken)
await users.recordLogin(user.id)
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } })
log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip })
return res.json({
user: { id: user.id, username: user.username, role: user.role },
returnTo: sanitizeReturn(pending.returnTo) || '/admin',
})
} catch (err) {
log.error('sso totp error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Attach the external identity to the account that initiated linking (tx.linkUserId
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
async function finishLink(req, res, providerId, tx, profile) {
@@ -173,4 +239,4 @@ async function finishLink(req, res, providerId, tx, profile) {
return res.redirect(`/admin/account?linked=${providerId}`)
}
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishLink }
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }

View File

@@ -1,11 +1,18 @@
const express = require('express')
const { body } = require('express-validator')
const ctrl = require('./sso.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const { ssoStartLimiter } = require('../../../middleware/rateLimit')
const { ssoStartLimiter, loginLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
const ssoRouter = express.Router()
// Same throttling stack the local login/TOTP endpoints use — the SSO TOTP step is
// a code-guessing surface too (cheapest rejection first).
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
// Public discovery — the login page reads this to render provider buttons.
ssoRouter.get(
'/providers',
@@ -57,4 +64,22 @@ ssoRouter.get(
ctrl.callback,
)
// Second factor for an SSO login whose account has TOTP enabled. The callback
// stages an httpOnly pending-TOTP cookie and bounces the browser to the login
// page (?sso_totp=1); the page posts the code here to finish and receive a session.
ssoRouter.post(
'/sso/totp',
// #swagger.tags = ['Auth · SSO']
// #swagger.summary = 'Complete an SSO login with a TOTP code'
// #swagger.description = 'Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["code"], properties: { code: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/SafeUser" }, returnTo: { type: "string" } } } } } } */
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
ctrl.finishSsoTotp,
)
module.exports = ssoRouter

View File

@@ -6,7 +6,8 @@ const ctrl = require('./internal.controller')
const router = express.Router()
// Shared-secret gated, not session-gated — the caller is the bot process, not
// a logged-in browser. Mounted before any auth/session middleware in v1.router.
// a logged-in browser. This router is mounted on the standalone internalApp
// (its own unpublished port), never on the public /api app. See internalApp.js.
router.use(requireInternalKey)
router.get(

View File

@@ -22,7 +22,7 @@ publicRouter.get(
// #swagger.tags = ['Public']
// #swagger.summary = 'Site mode / status'
// #swagger.description = 'Current site mode (live or maintenance) so the client can show the maintenance page.'
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string", example: "live" } } } } } } */
/* #swagger.responses[200] = { description: 'Site status', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicStatus" } } } } */
ctrl.getStatus,
)
publicRouter.post(

View File

@@ -5,13 +5,13 @@ const v1Router = express.Router()
const authRouter = require('./auth/auth.routes')
const publicRouter = require('./public/public.routes')
const adminRouter = require('./admin/admin.routes')
const internalRouter = require('./internal/internal.routes')
v1Router.use('/auth', authRouter)
v1Router.use('/public', publicRouter)
v1Router.use('/admin', adminRouter)
// Shared-secret gated (not session-gated) — server<->bot only, never exposed
// through the public reverse proxy. See server/src/middleware/requireInternalKey.js.
v1Router.use('/internal', internalRouter)
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
// decrypted Discord bot token and must never share the public listener that
// Pangolin proxies. They live on a separate, unpublished port via
// server/src/internalApp.js (started in server.js). See issue #33.
module.exports = v1Router

View File

@@ -2,16 +2,24 @@ require('dotenv').config()
const http = require('http')
const app = require('./app')
const internalApp = require('./internalApp')
const botScore = require('./middleware/botScore')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mailer = require('./utils/mailer')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const pkg = require('../package.json')
const log = createLogger('server')
const PORT = Number(process.env.PORT) || 3000
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
// the public PORT so the decrypted-token route can't ride the listener Pangolin
// proxies to the world (issue #33). Must match the port in the bot's
// SITE_INTERNAL_URL (docker-compose.yml).
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
async function start() {
@@ -25,12 +33,35 @@ async function start() {
smtp: mailer.isConfigured() ? 'configured' : 'not configured (mailto fallback)',
})
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
// production (the /internal/bot-config route hands back the decrypted Discord
// token and this key is its only guard); a warning otherwise.
const keyCheck = evaluateBotInternalKey({
key: process.env.BOT_INTERNAL_KEY,
nodeEnv: process.env.NODE_ENV,
})
if (keyCheck.fatal) {
log.error(keyCheck.message)
process.exit(1)
} else if (!keyCheck.ok) {
log.warn(keyCheck.message)
}
log.info('ensuring database schema...')
await ensureSchema()
log.info('seeding defaults...')
await seedDefaults()
await createInitialAdminFromEnv()
// Clear out session-denylist rows whose token has already expired (dead weight).
// Best-effort — a prune failure must never block startup.
try {
const pruned = await revokedSessions.pruneExpired()
if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
} catch (err) {
log.warn('revoked-session prune failed', { error: err.message })
}
const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
@@ -39,10 +70,18 @@ async function start() {
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
})
setupShutdown(server)
// Internal server<->bot API on a separate, unpublished port. NEVER expose this
// through Pangolin/the public reverse proxy — it serves the decrypted Discord
// bot token to the bot process over the private compose network only (#33).
const internalServer = http.createServer(internalApp)
internalServer.listen(INTERNAL_PORT, HOST, () => {
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
})
setupShutdown(server, internalServer)
}
function setupShutdown(server) {
function setupShutdown(server, internalServer) {
let closing = false
const shutdown = async (signal) => {
if (closing) return
@@ -50,6 +89,7 @@ function setupShutdown(server) {
log.warn(`${signal} received — shutting down gracefully`)
botScore.stopSweeper() // stop the bot-store cleanup interval
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {
await close()
log.info('database pool closed')

View File

@@ -0,0 +1,43 @@
// Boot-time validation of BOT_INTERNAL_KEY — the shared secret that is the ONLY
// auth on the server<->bot /internal/* routes (which return the decrypted Discord
// bot token). A missing, placeholder, or trivially short key would leave that
// endpoint effectively unguarded, so in production we refuse to start; in dev we
// warn but continue so local work isn't blocked. See issue #33.
// The placeholders shipped in the repo's .env.example files. If any of these
// reaches production it means the operator never generated a real key.
const PLACEHOLDERS = new Set([
'change-me-to-a-long-random-string', // root .env.example
'dev-only-change-me-bot-key', // server/.env.example, bot/.env.example
])
const MIN_LENGTH = 16
// Returns { ok, fatal, message }. `fatal` is only ever true in production —
// callers should exit non-zero on fatal, and log a warning (but continue) when
// !ok && !fatal.
function evaluateBotInternalKey({ key, nodeEnv } = {}) {
const value = key || ''
let reason = null
if (value.length === 0) reason = 'BOT_INTERNAL_KEY is not set'
else if (PLACEHOLDERS.has(value)) reason = 'BOT_INTERNAL_KEY is still the documented placeholder value'
else if (value.length < MIN_LENGTH) reason = `BOT_INTERNAL_KEY is too short (< ${MIN_LENGTH} chars)`
if (!reason) return { ok: true, fatal: false, message: null }
const production = nodeEnv === 'production'
const detail =
`${reason}. It is the only guard on /internal/bot-config, which returns the ` +
'decrypted Discord bot token.'
return {
ok: false,
fatal: production,
message: production
? `${detail} Refusing to start in production — set a long random BOT_INTERNAL_KEY (matching bot/.env).`
: `${detail} Continuing because NODE_ENV is not "production" — set a strong value before deploying.`,
}
}
module.exports = { evaluateBotInternalKey, PLACEHOLDERS, MIN_LENGTH }

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,7 @@ const doc = {
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
{ name: 'Admin · Activity', description: 'Admin activity log' },
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
{ name: 'Admin · Users', description: 'User management (admin only)' },
],
@@ -144,7 +145,11 @@ const doc = {
properties: {
accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
expiresIn: { type: 'integer', description: 'Access token lifetime in seconds.', example: 900 },
expiresIn: {
type: 'string',
description: 'Access token lifetime as a duration string (zeit/ms format, e.g. "15m").',
example: '15m',
},
user: { $ref: '#/components/schemas/SafeUser' },
},
},
@@ -173,27 +178,59 @@ const doc = {
name: { type: 'string', maxLength: 100, example: 'Lord British' },
},
},
// Public discovery shape (GET /auth/providers) — enough for the login page
// to render a button and start the flow. Never exposes secrets or endpoints.
Provider: {
type: 'object',
properties: {
id: { type: 'string', example: 'google' },
name: { type: 'string', example: 'Google' },
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
icon: {
type: 'string',
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
example: 'google',
},
loginUrl: {
type: 'string',
description: 'Relative URL to begin the redirect flow.',
example: '/api/v1/auth/sso/google/start',
},
priority: { type: 'integer', description: 'Sort order (ascending).', example: 1 },
},
},
// Admin-facing provider config (GET/POST/PUT /admin/auth/providers). The
// client secret is write-only and NEVER returned — `hasSecret` reports
// whether one is stored. `builtin` marks google/discord (fixed kind/name),
// and `health` is the config-completeness check used to gate visibility.
ProviderConfig: {
type: 'object',
properties: {
id: { type: 'string', example: 'okta' },
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
name: { type: 'string', example: 'Okta' },
enabled: { type: 'boolean', example: true },
clientId: { type: 'string' },
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
authorizeUrl: { type: 'string', format: 'uri' },
tokenUrl: { type: 'string', format: 'uri' },
userinfoUrl: { type: 'string', format: 'uri' },
scopes: { type: 'string', example: 'openid email profile' },
priority: { type: 'integer', example: 10 },
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
health: { $ref: '#/components/schemas/ProviderHealth' },
},
},
ProviderHealth: {
type: 'object',
description: 'Config-completeness check that gates whether a provider is offered to end users.',
properties: {
valid: { type: 'boolean', example: true },
missing: {
type: 'array',
description: 'Names of required config fields that are still missing.',
items: { type: 'string' },
example: [],
},
},
},
ProviderCreateRequest: {
@@ -220,11 +257,14 @@ const doc = {
category: { type: 'string', example: 'news' },
title: { type: 'string', example: 'Server maintenance this weekend' },
slug: { type: 'string', example: 'server-maintenance-this-weekend' },
body: { type: 'string' },
image_url: { type: 'string', example: '/uploads/1700000000-abcd.png' },
excerpt: { type: 'string', nullable: true },
body: { type: 'string', nullable: true },
image_url: { type: 'string', nullable: true, example: '/uploads/1700000000-abcd.png' },
published: { type: 'boolean', example: true },
author_id: { type: 'integer', nullable: true, example: 1 },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' },
published_at: { type: 'string', format: 'date-time', nullable: true },
},
},
PostCreateRequest: {
@@ -330,6 +370,84 @@ const doc = {
required: ['ip'],
properties: { ip: { type: 'string', example: '203.0.113.5' } },
},
// ── Actual mutation-response shapes ─────────────────────────────────────
// These endpoints do NOT return the generic { message } envelope; they echo
// the affected resource id/slug or a boolean flag. Documented here as-is so
// the spec matches the controllers. (The shapes are intentionally recorded
// rather than normalized — see the audit note if standardizing later.)
AccountStatus: {
type: 'object',
description: 'Self-service account security status (GET /admin/account).',
properties: {
id: { type: 'integer', example: 1 },
username: { type: 'string', example: 'admin' },
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
totp_enabled: { type: 'boolean', example: true },
},
},
TotpSetup: {
type: 'object',
description: 'Enrollment material returned by POST /account/totp/setup.',
properties: {
otpauthUrl: { type: 'string', example: 'otpauth://totp/UOMysticmoon:admin?secret=...' },
qr: { type: 'string', description: 'QR code as a data: URL.', example: 'data:image/png;base64,iVBORw0KGgo...' },
},
},
TotpState: {
type: 'object',
description: 'Result of enabling/disabling 2FA.',
properties: { totp_enabled: { type: 'boolean', example: true } },
},
LinkedIdentity: {
type: 'object',
properties: {
provider: { type: 'string', example: 'google' },
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
linked_at: { type: 'string', format: 'date-time' },
},
},
SiteModeState: {
type: 'object',
description: 'Result of PUT /admin/site-mode.',
properties: {
site_mode: { type: 'string', enum: ['live', 'maintenance'], example: 'maintenance' },
changed_at: { type: 'string', format: 'date-time' },
changed_by: { type: 'string', example: 'admin' },
},
},
PublicStatus: {
type: 'object',
description: 'Public site status (GET /public/status).',
properties: {
mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' },
status_message: { type: 'string', example: '' },
},
},
// Delete/mutation acknowledgements — each echoes the affected resource key
// or a boolean flag rather than a { message } string.
DeletedId: {
type: 'object',
properties: { id: { type: 'integer', example: 12 } },
},
DeletedSlug: {
type: 'object',
properties: { slug: { type: 'string', example: 'getting-started' } },
},
DeletedFlag: {
type: 'object',
properties: { deleted: { type: 'boolean', example: true } },
},
UnlinkedFlag: {
type: 'object',
properties: { unlinked: { type: 'boolean', example: true } },
},
UnbanResult: {
type: 'object',
properties: {
ip: { type: 'string', example: '203.0.113.5' },
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
},
},
},
},
}

View File

@@ -0,0 +1,55 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { evaluateBotInternalKey, MIN_LENGTH } = require('../src/utils/botInternalKey')
const STRONG = 'x'.repeat(MIN_LENGTH + 8)
test('a strong key is ok in every environment', () => {
for (const nodeEnv of ['production', 'development', undefined]) {
const r = evaluateBotInternalKey({ key: STRONG, nodeEnv })
assert.equal(r.ok, true)
assert.equal(r.fatal, false)
assert.equal(r.message, null)
}
})
test('empty key is fatal in production, warn otherwise', () => {
const prod = evaluateBotInternalKey({ key: '', nodeEnv: 'production' })
assert.equal(prod.ok, false)
assert.equal(prod.fatal, true)
const dev = evaluateBotInternalKey({ key: '', nodeEnv: 'development' })
assert.equal(dev.ok, false)
assert.equal(dev.fatal, false)
})
test('undefined key behaves like empty', () => {
const r = evaluateBotInternalKey({ key: undefined, nodeEnv: 'production' })
assert.equal(r.ok, false)
assert.equal(r.fatal, true)
})
test('documented placeholders are rejected (fatal in production)', () => {
for (const key of ['change-me-to-a-long-random-string', 'dev-only-change-me-bot-key']) {
const r = evaluateBotInternalKey({ key, nodeEnv: 'production' })
assert.equal(r.ok, false, `placeholder should be rejected: ${key}`)
assert.equal(r.fatal, true)
}
})
test('a too-short key is rejected', () => {
const short = 'a'.repeat(MIN_LENGTH - 1)
const r = evaluateBotInternalKey({ key: short, nodeEnv: 'production' })
assert.equal(r.ok, false)
assert.equal(r.fatal, true)
const dev = evaluateBotInternalKey({ key: short, nodeEnv: 'development' })
assert.equal(dev.ok, false)
assert.equal(dev.fatal, false)
})
test('a key exactly MIN_LENGTH long is accepted', () => {
const r = evaluateBotInternalKey({ key: 'a'.repeat(MIN_LENGTH), nodeEnv: 'production' })
assert.equal(r.ok, true)
})

View File

@@ -0,0 +1,89 @@
// Unit tests for the moderation dashboard's pure reshaping/annotation logic.
// DB-free (like the rest of this suite) — the SQL layer is exercised manually
// against a dev database per the plan's verification steps.
const { test } = require('node:test')
const assert = require('node:assert/strict')
const moderation = require('../src/model/moderation/moderation.pure')
test('reshapeWindows: folds rows into windows and zero-fills missing types', () => {
const rows = [
{ action_type: 'ban', d1: 1, d7: 3, d30: 5 },
{ action_type: 'warn', d1: 0, d7: 2, d30: 9 },
]
const { windows } = moderation.reshapeWindows(rows)
assert.deepEqual(windows['24h'], { ban: 1, kick: 0, mute: 0, warn: 0 })
assert.deepEqual(windows['7d'], { ban: 3, kick: 0, mute: 0, warn: 2 })
assert.deepEqual(windows['30d'], { ban: 5, kick: 0, mute: 0, warn: 9 })
})
test('reshapeWindows: coerces string/decimal SUM results to numbers', () => {
const { windows } = moderation.reshapeWindows([{ action_type: 'mute', d1: '2', d7: '2', d30: '4' }])
assert.strictEqual(windows['24h'].mute, 2)
assert.strictEqual(windows['30d'].mute, 4)
})
test('reshapeWindows: ignores unknown action types (e.g. future enum values)', () => {
const { windows } = moderation.reshapeWindows([{ action_type: 'filter_hit', d1: 9, d7: 9, d30: 9 }])
assert.deepEqual(windows['24h'], { ban: 0, kick: 0, mute: 0, warn: 0 })
})
test('reshapeWindows: empty input yields all-zero windows', () => {
const { windows } = moderation.reshapeWindows([])
assert.deepEqual(windows, {
'24h': { ban: 0, kick: 0, mute: 0, warn: 0 },
'7d': { ban: 0, kick: 0, mute: 0, warn: 0 },
'30d': { ban: 0, kick: 0, mute: 0, warn: 0 },
})
})
test('annotate: flags automated when staff id matches the bot application id', () => {
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], '999')
assert.equal(row.is_automated, true)
})
test('annotate: string/number snowflake mismatch still matches (coerced)', () => {
// mod_actions stores staff_user_id as VARCHAR, but bot_config.application_id
// could arrive as a number — the compare must coerce both sides.
const [row] = moderation.annotate([{ staff_user_id: 999, target_site_user_id: null }], '999')
assert.equal(row.is_automated, true)
})
test('annotate: staff action (id differs from bot) is not automated', () => {
const [row] = moderation.annotate([{ staff_user_id: '111', target_site_user_id: null }], '999')
assert.equal(row.is_automated, false)
})
test('annotate: no bot application id configured means nothing is automated', () => {
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], null)
assert.equal(row.is_automated, false)
})
test('annotate: folds joined identity columns into linked_account', () => {
const [row] = moderation.annotate(
[{ staff_user_id: '1', target_site_user_id: 7, target_site_username: 'perry' }],
null,
)
assert.deepEqual(row.linked_account, { id: 7, username: 'perry' })
})
test('annotate: no linked identity yields null linked_account', () => {
const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null)
assert.equal(row.linked_account, null)
})
test('windowValue: picks the right column per window key and coerces to number', () => {
const row = { d1: '2', d7: 5, d30: '11' }
assert.strictEqual(moderation.windowValue(row, '24h'), 2)
assert.strictEqual(moderation.windowValue(row, '7d'), 5)
assert.strictEqual(moderation.windowValue(row, '30d'), 11)
})
test('windowValue: null row (no rows in window) yields 0', () => {
assert.strictEqual(moderation.windowValue(null, '24h'), 0)
assert.strictEqual(moderation.windowValue(undefined, '30d'), 0)
})
test('windowValue: null sum column yields 0', () => {
assert.strictEqual(moderation.windowValue({ d1: null, d7: null, d30: null }, '7d'), 0)
})

View File

@@ -0,0 +1,66 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const requireInternalKey = require('../src/middleware/requireInternalKey')
const { startApp } = require('./_helper')
const KEY = 'test-internal-key-1234567890'
async function withApp(run) {
const prev = process.env.BOT_INTERNAL_KEY
process.env.BOT_INTERNAL_KEY = KEY
const app = await startApp((a) => {
a.get('/internal/thing', requireInternalKey, (req, res) => res.json({ ok: true }))
})
try {
await run(app)
} finally {
await app.close()
if (prev === undefined) delete process.env.BOT_INTERNAL_KEY
else process.env.BOT_INTERNAL_KEY = prev
}
}
test('requireInternalKey: 401 when no key header is sent', async () => {
await withApp(async (app) => {
const res = await fetch(`${app.url}/internal/thing`)
assert.equal(res.status, 401)
})
})
test('requireInternalKey: 401 on a wrong key', async () => {
await withApp(async (app) => {
const res = await fetch(`${app.url}/internal/thing`, {
headers: { 'X-Internal-Key': 'nope' },
})
assert.equal(res.status, 401)
})
})
test('requireInternalKey: 200 with the correct key', async () => {
await withApp(async (app) => {
const res = await fetch(`${app.url}/internal/thing`, {
headers: { 'X-Internal-Key': KEY },
})
assert.equal(res.status, 200)
const body = await res.json()
assert.deepEqual(body, { ok: true })
})
})
test('requireInternalKey: 401 when the expected key is empty (never a wildcard)', async () => {
const prev = process.env.BOT_INTERNAL_KEY
process.env.BOT_INTERNAL_KEY = ''
const app = await startApp((a) => {
a.get('/internal/thing', requireInternalKey, (req, res) => res.json({ ok: true }))
})
try {
// Even sending an empty key must not match an empty expected key.
const res = await fetch(`${app.url}/internal/thing`, { headers: { 'X-Internal-Key': '' } })
assert.equal(res.status, 401)
} finally {
await app.close()
if (prev === undefined) delete process.env.BOT_INTERNAL_KEY
else process.env.BOT_INTERNAL_KEY = prev
}
})

View File

@@ -10,7 +10,10 @@ const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const sessionService = require('../src/auth/session.service')
const ssoState = require('../src/auth/ssoState')
const authFacade = require('../src/utils/auth')
const revokedSessions = require('../src/model/revokedSessions/revokedSessions.model')
const usersModel = require('../src/model/users/users.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -36,6 +39,9 @@ test('createSession → validateSession round-trips a Session object', () => {
assert.equal(session.authMethod, 'local')
assert.ok(session.sessionId, 'sessionId (jti) is present')
assert.equal(typeof session.createdAt, 'number')
// exp is carried so logout can set a self-pruning denylist row expiry.
assert.equal(typeof session.expiresAt, 'number')
assert.ok(session.expiresAt > session.createdAt, 'expiresAt is after createdAt')
// Validating the same token off a request yields the same identity.
const validated = sessionService.validateSession(reqWithCookie(token))
@@ -68,6 +74,28 @@ test('a partial (TOTP challenge) token is NOT a valid session', () => {
assert.equal(sessionService.decodeIdentity(challenge), null)
})
test('an SSO transaction (sso_tx) flow token is NOT a valid session (issue #32)', () => {
// The sso_tx cookie is a JWT signed with the same secret as sessions, carrying
// kind:'sso_tx' and id:'sso' but no `stage`. Before the fix it passed the
// blocklist check and validated as a bogus { userId:'sso' } session, which fooled
// non-DB identity checks (e.g. siteMode's maintenance-preview bypass).
const { txToken } = ssoState.createTx({ provider: 'google', mode: 'login' })
assert.equal(sessionService.validateSession(reqWithCookie(txToken)), null)
assert.equal(sessionService.validateSession(reqWithBearer(txToken)), null)
assert.equal(sessionService.decodeIdentity(txToken), null)
assert.equal(sessionService.validateBearerToken(txToken), null)
// And the historical facade used by siteMode must report no user.
assert.equal(authFacade.getUserFromRequest(reqWithCookie(txToken)), null)
})
test('a bare identity token with no session marker is NOT a valid session', () => {
// A JWT carrying only { id, username, role } (e.g. a legacy token, or one minted
// for some other purpose) must not validate: sessions are positively typed.
const bare = authFacade.signToken(USER)
assert.equal(sessionService.validateSession(reqWithCookie(bare)), null)
assert.equal(sessionService.decodeIdentity(bare), null)
})
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
const challenge = sessionService.createPartialSession(USER)
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
@@ -86,10 +114,62 @@ test('validateSession / decodeIdentity return null for missing or garbage input'
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
})
test('revoke / invalidate stubs report success without throwing', () => {
assert.equal(sessionService.revokeSession('sid-1'), true)
assert.equal(sessionService.invalidateSession('sid-1'), true)
assert.equal(sessionService.invalidateAllUserSessions(USER.id), true)
test('revokeSession denylists the jti with the token expiry', async () => {
// Stub the store (the DB is intentionally unreachable in these tests) and
// capture what the seam persists. sessionService holds the same module object,
// so overwriting the method here is what it calls.
const calls = []
const orig = revokedSessions.revoke
revokedSessions.revoke = async (args) => { calls.push(args); return 1 }
try {
const exp = Date.now() + 60_000
const ok = await sessionService.revokeSession('sid-1', { userId: USER.id, expiresAt: exp })
assert.equal(ok, true)
assert.equal(calls.length, 1)
assert.equal(calls[0].jti, 'sid-1')
assert.equal(calls[0].userId, USER.id)
assert.equal(calls[0].expiresAt, exp)
} finally {
revokedSessions.revoke = orig
}
})
test('revokeSession is a no-op (returns false) without a sessionId', async () => {
let called = false
const orig = revokedSessions.revoke
revokedSessions.revoke = async () => { called = true; return 1 }
try {
assert.equal(await sessionService.revokeSession(undefined), false)
assert.equal(called, false, 'nothing is persisted when there is no jti')
} finally {
revokedSessions.revoke = orig
}
})
test('isSessionRevoked delegates to the denylist (and short-circuits on null)', async () => {
const orig = revokedSessions.isRevoked
revokedSessions.isRevoked = async (jti) => jti === 'revoked-sid'
try {
assert.equal(await sessionService.isSessionRevoked('revoked-sid'), true)
assert.equal(await sessionService.isSessionRevoked('fresh-sid'), false)
assert.equal(await sessionService.isSessionRevoked(null), false)
} finally {
revokedSessions.isRevoked = orig
}
})
test('invalidateAllUserSessions bumps the user cutoff (and guards a missing id)', async () => {
const ids = []
const orig = usersModel.invalidateSessions
usersModel.invalidateSessions = async (id) => { ids.push(id); return undefined }
try {
assert.equal(await sessionService.invalidateAllUserSessions(USER.id), true)
assert.deepEqual(ids, [USER.id])
assert.equal(await sessionService.invalidateAllUserSessions(undefined), false)
assert.deepEqual(ids, [USER.id], 'no bump when userId is missing')
} finally {
usersModel.invalidateSessions = orig
}
})
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {

View File

@@ -15,6 +15,7 @@ const activity = require('../src/model/activity/activity.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const registry = require('../src/auth/providers/registry')
const totp = require('../src/utils/totp')
const db = require('../src/utils/db')
after(() => db.close())
@@ -114,3 +115,75 @@ test('bad state (CSRF) → rejected before any provider work', async () => {
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})
// ── 2FA parity: SSO must not bypass TOTP (issue #31) ────────────────────────
test('linked account with TOTP → staged challenge, NO session, routed to TOTP', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
users.getById = async (id) => ({ id, username: 'alice', role: 'admin', totp_enabled: 1 })
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no full session before 2FA')
assert.ok(res.cookies[ssoState.TOTP_COOKIE], 'pending-TOTP cookie staged')
assert.equal(res.redirectedTo, '/admin/login?sso_totp=1')
assert.equal(logged.length, 0, 'login not logged until the second factor passes')
// The staged cookie carries the resolved context and is not a usable session.
const pending = ssoState.verifyTotpPending(res.cookies[ssoState.TOTP_COOKIE])
assert.equal(pending.id, 7)
assert.equal(pending.provider, 'google')
assert.equal(pending.returnTo, '/admin/posts')
})
function makeTotpReq(pendingToken, code) {
return {
cookies: pendingToken ? { [ssoState.TOTP_COOKIE]: pendingToken } : {},
body: { code },
ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
}
}
test('finishSsoTotp: correct code → session issued, pending cookie cleared, login logged', async () => {
users.getRawById = async (id) => ({ id, username: 'alice', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
totp.verifyCode = () => true
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/admin/posts' })
const res = mockRes()
await ssoCtrl.finishSsoTotp(makeTotpReq(pending, '123456'), res)
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set after 2FA')
assert.ok(res.cleared.includes(ssoState.TOTP_COOKIE), 'pending-TOTP cookie cleared')
assert.equal(res.body.returnTo, '/admin/posts')
assert.equal(res.body.user.id, 7)
assert.equal(logged.at(-1).action, 'auth.sso.login')
assert.equal(logged.at(-1).detail.totp, true)
})
test('finishSsoTotp: wrong code → 401, no session', async () => {
users.getRawById = async (id) => ({ id, username: 'alice', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
totp.verifyCode = () => false
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google' })
const res = mockRes()
await ssoCtrl.finishSsoTotp(makeTotpReq(pending, '000000'), res)
assert.equal(res.statusCode, 401)
assert.match(res.body.message, /Invalid verification code/)
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})
test('finishSsoTotp: missing/expired pending cookie → 401 expired', async () => {
const res = mockRes()
await ssoCtrl.finishSsoTotp(makeTotpReq(null, '123456'), res)
assert.equal(res.statusCode, 401)
assert.match(res.body.message, /expired/i)
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})
test('finishSsoTotp: a local /login/totp challenge is not accepted here', async () => {
// A stage:'totp' token without kind:'sso_totp' must be rejected by this endpoint.
const localChallenge = token.signTotpChallenge({ id: 7 })
const res = mockRes()
await ssoCtrl.finishSsoTotp(makeTotpReq(localChallenge, '123456'), res)
assert.equal(res.statusCode, 401)
assert.match(res.body.message, /expired/i)
})

View File

@@ -1,10 +1,18 @@
// Point the DB at a closed port before requiring the auth layer: session.service
// (used by one test below) pulls in the users model → db pool at load, and an idle
// pool would keep this process alive. None of these tests touch the database.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test } = require('node:test')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const crypto = require('crypto')
const ssoState = require('../src/auth/ssoState')
const db = require('../src/utils/db')
after(() => db.close())
test('createTx → verifyTx round-trips the flow payload', () => {
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
@@ -36,3 +44,27 @@ test('verifyTx rejects a non-tx token', () => {
const notTx = token.signToken({ id: 1, username: 'a', role: 'admin' })
assert.equal(ssoState.verifyTx(notTx, 'anything'), null)
})
test('createTotpPending → verifyTotpPending round-trips the SSO 2FA context', () => {
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google', returnTo: '/admin/posts' })
const payload = ssoState.verifyTotpPending(pending)
assert.ok(payload)
assert.equal(payload.id, 7)
assert.equal(payload.provider, 'google')
assert.equal(payload.authMethod, 'google')
assert.equal(payload.returnTo, '/admin/posts')
assert.equal(payload.stage, 'totp')
})
test('a pending-TOTP token is NOT accepted as a session (stage + kind reject it)', () => {
const sessionService = require('../src/auth/session.service')
const pending = ssoState.createTotpPending({ userId: 7, provider: 'google', authMethod: 'google' })
assert.equal(sessionService.decodeIdentity(pending), null)
})
test('verifyTotpPending rejects a plain session and a bare TOTP challenge', () => {
const token = require('../src/auth/token')
assert.equal(ssoState.verifyTotpPending(token.signToken({ id: 1, username: 'a', role: 'admin' })), null)
assert.equal(ssoState.verifyTotpPending(token.signTotpChallenge({ id: 1 })), null)
assert.equal(ssoState.verifyTotpPending(null), null)
})