Commit Graph

93 Commits

Author SHA1 Message Date
e7f5f24809 Regenerate Swagger with the CMS pages endpoints
swagger-output.json now documents GET/POST /admin/pages, GET/PATCH/DELETE
/admin/pages/:id, POST /admin/pages/:id/{unprotect,preview}, and the public
GET /public/pages/:slug + /public/pages/:id/preview/:token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:57:39 -05:00
1dd7603f54 Add page builder admin UI + public page route (step 5 + step 6 client)
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
  new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
  per-block editor cards with show/hide, up/down + native drag reorder, and
  remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
  publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
  draft preview (mints a token, opens /preview/:id/:token); delete (blocked
  while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
  token-gated /preview/:id/:token, rendering blocks via BlockList and
  reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
  outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.

Client builds clean (216 modules).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:36 -05:00
4d87c5f627 Add pages API: model, controller, routes, preview (steps 4/6/7/8)
Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:48:31 -05:00
764fb0c069 Add Wave 1 block renderers + editors (page builder step 3, client half)
Client block registry now carries a renderer, edit form, palette label/icon,
and defaults for all seven Wave 1 blocks (self-registering via
client/src/blocks/types/*): heading, rich_text, image, two_column, cta,
divider, quote.

- BlockRenderer + BlockList render stored blocks via the registry (respect
  `visible`, tolerate unknown types), reading getBlock from ./registry to
  avoid the index -> twoColumn -> BlockRenderer cycle.
- editorKit: shared Field/TextField/TextAreaField/SelectField styled with the
  existing admin form classes; rich_text editor reuses RichTextEditor
  (variant post), image editor reuses the shared uploader.
- two_column editor is a mini per-column canvas (add from the leaf-only
  palette, edit via each block's registry editor, reorder, remove).
- theme.css: public block styles (heading/image alignment/cta/quote/
  two-column responsive grid) + column sub-block editor styles.

Verified: all 11 modules transform cleanly under esbuild. Full visual
verification comes with the builder UI (step 5) + public route (step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:37:34 -05:00
6d31869ba2 Add Wave 1 block server schemas (page builder step 3, server half)
Register all seven Wave 1 block types with their server-side validation
schemas, self-registering via server/src/blocks/types/*:
heading, rich_text, image, two_column (container), cta, divider, quote.

- propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/
  protocol-relative, enum/required/optional text, strict key allowlist).
- rich_text carries a `sanitize` normalizer (registry now supports it) that
  runs html through the shared cleanBody allowlist on save.
- Registry entrypoint requires the type modules so all schemas load.

Verified: all 7 register; valid blocks pass; malformed props yield precise
per-path errors; one-level nesting cap enforced; rich_text sanitize strips
script/onerror.

Client renderers + editors (step 3 client half) still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:30:58 -05:00
fcef08e9b6 Add pages table + block registry scaffold (page builder step 2)
New `pages` table: slug/title/blocks(JSON-as-text)/status/protected, author
FK, grouped SEO metadata + layout/nav settings columns (added up front per
spec — cheap now, painful to retrofit), published_at mirroring posts.

Block registry scaffold, server and client, defining the pattern without
any block types yet (Wave 1 lands in step 3):
- server/src/blocks: registry (register/get/list, reserved envelope keys,
  container metadata) + validateBlocks (authoritative save-time gate:
  envelope, registered-type, per-block schema, one-level nesting cap) +
  index entrypoint that will register Wave 1 defs.
- client/src/blocks: mirror registry carrying renderer/editor/palette +
  makeBlockId, plus index entrypoint.

Verified: schema applies idempotently against the dev DB (pages table +
indexes present); validator exercised for empty/non-array/unknown-type/
bad-envelope/duplicate-id/nested-container cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:26:14 -05:00
6180e8a071 Add rich-text alignment controls (left/center/right)
Shared RichTextEditor gains @tiptap/extension-text-align for heading and
paragraph nodes, serializing alignment as inline text-align on the block
node so it round-trips through save/reload. Fixed once at the shared
component so it also flows into the upcoming rich_text and two_column
page blocks.

Server sanitize allowlist now permits `style` on p/h1-h6, constrained by
allowedStyles to text-align (left/right/center/justify) only; all other
CSS properties and values are stripped.

Step 1 of the CMS Page Builder spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:21:17 -05:00
d7fc2dccb7 Merge pull request 'Modernize email: Gmail OAuth2 sending + admin sidebar redesign' (#46) from feature/email-oauth2 into main
Reviewed-on: UOM/website#46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 03:33:42 +00:00
455e850b91 Merge branch 'main' into feature/email-oauth2 2026-07-08 03:33:11 +00:00
f8652c2399 Modernize email: Gmail OAuth2 sending, configured under Settings
Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.

- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
  row), contact recipient = contact_email setting, mailto: fallback preserved,
  plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
  + PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
  now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
2026-07-07 22:29:27 -05:00
5b3ab7f282 Merge pull request 'Redesign admin/staff sidebar: collapsible categories, icons, role-accurate nav' (#45) from feature/admin-nav-redesign into main
Reviewed-on: UOM/website#45
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 02:56:15 +00:00
17d42cebfe Redesign admin sidebar: collapsible categories, icons, role-accurate nav
Regroup the flat 12-link staff sidebar into collapsible category sections
(Content / Moderation / System, with Dashboard and Account ungrouped) and
add a small inline-SVG icon per item. Category collapse state persists in
localStorage and the group holding the active route auto-opens.

Gate each item by role to match server-side enforcement so the sidebar no
longer shows links that would 403: Content is admin/editor, Moderation is
admin/moderator, System (Users, Settings, Hero Editor, Authentication,
Discord Bot, Web Bot Activity) is admin-only. Existing moderator confinement
(Moderation + Account only, plus redirect) is preserved.

Rename "Bot Activity" to "Web Bot Activity" to distinguish the bot-scoring
view from the Discord Bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:54:37 -05:00
5da27879e5 Merge pull request 'Gate /admin to staff roles; role-aware login redirects for players' (#44) from feature/player-accounts into main
Reviewed-on: UOM/website#44
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-07 01:02:53 +00:00
cda0c16149 Merge branch 'main' into feature/player-accounts 2026-07-07 01:02:39 +00:00
82807d18d9 Gate /admin to staff roles; role-aware login redirects for players
Introducing the 'player' role turned 'logged-in' into 'logged-in but possibly
untrusted', but the admin router only gated content routes (dashboard, posts,
wiki, uploads) by isLoggedIn — so a player session could reach editor-tier
endpoints. Fixes:
- Backend: requireRole('admin','editor','moderator') at the admin router base;
  players now 403 on all /admin/* and use /player instead.
- Client: RequireAuth redirects a signed-in player to /account (mirrors
  RequirePlayer).
- Both login pages redirect by role after auth (player -> /account, staff ->
  /admin) so you land in the right shell whichever door you used.

Verified live: player token 403s on /admin/dashboard + /admin/users, 200s on
/player/account; browser click-through confirms a player at /admin and at
/admin/login both land on /account. 134 server tests green; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 19:57:31 -05:00
d72deff2cc Merge pull request 'Player accounts: self-service player role, registration, and portal' (#43) from feature/player-accounts into main
Reviewed-on: UOM/website#43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-06 20:29:42 +00:00
387db52510 Fix player getAccount has_password: read the raw row, not sanitized req.user
req.user comes from getById which strips password_hash, so has_password was
always false — the account page mis-rendered a real password account as the
SSO-only 'set a password' variant (and the change-password form omitted the
required current-password field). Read the raw row for that one flag.

Caught by a browser click-through of the /account portal. Adds a regression
test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 05:11:36 -05:00
5daf260db9 Player accounts frontend + Swagger + schema comment fix
- Player portal: RequirePlayer guard, /account routes (login, register,
  settings) with shared PlayerShell; register reads /public/settings derived
  flags; AuthContext.register; api.register + api.player.* namespace.
- Admin UI: player role + status/email + reset-password hint in UserEditor,
  status column + badge-player in UsersAdmin, player_registration select in
  SettingsAdmin; 'disabled' SSO error copy.
- Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/
  PlayerAccount/OkFlag schemas; regenerated swagger-output.json.
- Fix: remove a semicolon from a schema.sql inline comment that broke the
  statement splitter in ensureSchema.

Verified against the live dev DB: schema migrations apply (player enum,
nullable password_hash, email/status/last_login_ip, seeded setting); 21-check
controller smoke (register gating, dup/reserved, null-hash rules, self change
username/password with session re-issue surviving the cutoff, SSO-only initial
password, banned-login refusal); case-insensitive uniqueness; public settings
expose only derived registration flags. Client builds; 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:49:12 -05:00
f8bcc7f6a3 Player accounts backend: schema, registration, self-service, SSO provision
- Widen users.role enum to include 'player'; make password_hash nullable;
  add email/email_verified/status/last_login_ip; pin username _ci collation.
- POST /auth/register (honeypot + registerLimiter + botScore, reserved-name
  blocklist, duplicate->409, auto-login). player_registration setting gates it.
- SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO
  redirects for the player portal; status refusal on login + requireAuth.
- New /player self-service group (account, change username/password, TOTP,
  identities), reusing account.controller; accountChangeLimiter.
- Admin: 'player' role + status/email on user create/update, role/status audit,
  player_registration enum validation, derived public registration flags.
- usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests;
  extend SSO callback tests. 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:36:51 -05:00
cdd916e199 Ignore local .plans/ planning docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-06 01:17:16 -05:00
4ab46410be Merge pull request 'Moderation dashboard: staff dashboard, user history, notes, event capture (Phase 6a + 6b)' (#42) from feature/moderation-dashboard into main
Reviewed-on: UOM/website#42
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-05 20:51:38 +00:00
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
7a21cc636c Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)
Standalone bot/ service (its own package.json/Dockerfile) managed entirely
through a new admin-only Discord Bot panel — token stored encrypted in the
DB and pushed to the bot process in-memory, never an env var. Built in
phases, each independently verified against a live Discord guild:

- Bot skeleton: gateway connection, internal shared-secret API, self-heals
  on its own restart by pulling config from the site
- Moderation core: /ban /kick /mute /warn /warnings + mod-log channel
- Word/invite/spam filtering with leetspeak-resistant normalization and a
  staff role/channel allowlist
- Scheduled messages: recurring (cron) and one-off channel posts
- Role assignment: button role menus, auto-role on join, temp roles,
  bulk role ops
- Auto-rotating primary invite with an audit log
- Site integration: news-publish -> Discord announce webhook, manual
  /announce, read-only /wiki search

Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb
driver defaulted to timezone 'local', silently mis-serializing bound Date
params by the host's local offset instead of the DB's UTC session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:54:41 -05: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
0318d6fe9f Make hero the full landing page; move quick links into hero editor
Remove the two-card destination row and the below-hero quick-links nav
from the portal so the hero fills the viewport with nothing rendered
after it. The 5 quick links (News, Screenshots, Five on Friday,
Monthly Newsletter, About) move into the hero editor as a third
buttons element in defaultLayout(), reusing the existing buttons
element type so they stay fully editable with no schema changes.

Also drop overflow:hidden on the hero section: on mobile, 100vh can
compute smaller than window.innerHeight, and with overflow hidden the
wrapped quick-links text was getting clipped at the bottom edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:49:45 -05:00
433e02d3ef Merge pull request 'Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)' (#27) from feature/swagger-docs into main
Reviewed-on: UOM/website#27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 20:30:14 +00:00
a1f0675577 Add Swagger/OpenAPI API docs (swagger-ui + swagger-autogen)
Generate an OpenAPI 3.0 spec from route annotations and serve it with
Swagger UI so the full REST API is browsable and testable.

- Add swagger-ui-express (runtime) and swagger-autogen (dev) deps, plus
  an `npm run swagger` script.
- server/swagger/swagger.js: generator config with API metadata, servers,
  14 tag groups, cookie + bearer security schemes, and 28 reusable
  component schemas. Follows the Express mount chain from src/app.js so
  generated paths are fully-qualified (/api/v1/...).
- Annotate every route (auth, mobile, sso, public, admin, health) with
  #swagger tags/summaries/parameters/request bodies/security and the
  actual response codes each handler returns (400/401/403/404/409/429/
  302/502, multipart uploads).
- Serve Swagger UI at /api/docs and the raw spec at /api/docs.json,
  guarded so a missing spec disables docs instead of crashing.
- Commit the generated swagger-output.json so docs work with no build
  step; swagger-autogen stays dev-only and is not needed at runtime.
- README: new "API documentation (Swagger)" section plus tech-stack and
  project-structure entries.

Covers 51 paths / 64 operations. Existing test suite (83) still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:28:13 -05:00
d7fb274bad Merge pull request 'Hero editor: scale text-block fonts with the resize handle (#25)' (#26) from enhancement/hero into main
Reviewed-on: UOM/website#26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 20:01:50 +00:00
6af85c30b6 Hero editor: scale text block fonts with the resize handle (#25)
The text_block corner handle previously only changed the wrap width, so
the font size never tracked the box — making the editor un-WYSIWYG and
awkward to tune. Now dragging the handle scales every line's font
proportionally with the box, acting as a zoom that preserves the
h1/h2/p size ratios and keeps each line's manually-set baseline.

- Add scaleFontSize(): numeric px sizes (floored at 6px) and simple
  rem/em/px strings scale by the box ratio; responsive clamp()/vw
  strings are left untouched so the default hero stays fluid.
- Snapshot the box width + lines at drag start so scaling is computed
  against the origin (no rounding drift mid-drag).
- Update the canvas hint to note the handle scales text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:59:15 -05:00
86e44a94a2 Merge pull request 'Add session abstraction, mobile bearer auth, and pluggable SSO (Google/Discord/OIDC)' (#24) from feature/auth-session-abstraction into main
Reviewed-on: UOM/website#24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 15:35:44 +00:00
31b31c3a17 Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).

Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
  (create/validate/partial-TOTP/revoke), session.middleware.js
  (attachSession/requireAuth/requireRole). utils/auth.js is now a thin
  compat facade so existing imports are unchanged.

Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
  long-lived refresh token, stored hashed and rotated on use, in a new
  mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
  TOTP. token.signToken gains a backward-compatible expiresIn option.

Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
  OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
  discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
  identities are never auto-provisioned. Client secrets encrypted at rest
  (AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
  account linking (/admin/account/identities). New auth_providers +
  user_identities tables.

Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
  graceful with zero providers). New Authentication admin view
  (Local/Google/Discord/Custom). Account page linked-accounts section.

Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:31:29 -05:00
8fa34ca68e Merge pull request 'Add Bot Activity admin panel: banned-IP view + recent events + emergency unban' (#23) from feature/bot-activity-admin into main
Reviewed-on: UOM/website#23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-03 08:29:54 +00:00
870971fc12 Add Bot Activity admin panel: banned-IP view + recent events + emergency unban
Expose the botScore middleware's in-memory scoring/ban state to admins.
Previously state lived only in the store Map with no persistence or API — the
only visibility was tailing container logs.

- botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and
  ban events (most-recent-first); listState() snapshot of all scored IPs;
  unban() to clear a single IP.
- New admin-only endpoints GET /admin/bot-activity and
  POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is
  activity-logged with the admin username.
- Bot Activity tab: currently-banned table with Unban, plus a recent-events
  feed, following the existing admin table patterns.
- Tests for the buffer, listState, and unban (guard lets an unbanned IP back
  through). README updated.

Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is
in-memory, matching the store; not persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:31:25 -05:00