Adds a "PR 3 — as landed" section to API_V2_PLAN.md and ticks the sequencing
list. 31 routes extracted, 33 left in admin.routes.js; all four zero-diff gates
came back clean and 434 server tests passed.
Findings carried forward:
- The residual 33 is exactly PR 4's list, so admin.routes.js is deleted by
PR 4 rather than PR 5.
- First shared module in the split: the multer config, because POST
/posts/upload and POST /uploads no longer live in the same file.
- POST /uploads keeps its Admin · Posts swagger tag — retagging is a real
OpenAPI diff and does not belong in a route-move PR.
- The wiki router has load-bearing intra-file route order (/categories and
/tags ahead of /:slug) that no gate can catch, because the manifest sorts
its entries. Verified by introspecting the built router stack instead.
BACKEND_DESIGN.md §2 gets the four new routers plus imageUpload.js in the
folder tree, and §4 notes that the content capabilities add no gate beyond
staffOnly.
Co-Authored-By: Claude <noreply@anthropic.com>
Matches the code change in website: 18 more admin routes carved into
moderation.router.js (15), botActivity.router.js (2) and activity.router.js (1),
leaving 64 in the residual admin.routes.js.
API_V2_PLAN.md gains a "PR 2 — as landed" section recording the four zero-diff
gates and two decisions worth carrying into PRs 3-5:
- /activity gets its own file rather than the target tree's plan to park it as
a singleton inside dashboard.router.js — honouring the tree would have left
one route in the residual file for two PRs, and it is a genuinely separate
capability (the staff audit log, not the dashboard's stats overview and not
the botScore middleware's ban state). PR 4 therefore mounts dashboard and
site-mode only; the target tree is updated to match.
- A gate moves to a router-level `use` only where it was already a *prefix*
mount (moderation's modAccess). Bot-activity's per-route adminOnly stays
per-route, because the per-route handler count is the only thing in
routes.guards.json that would catch a dropped gate — requireRole(...) returns
an anonymous arrow and never appears by name.
BACKEND_DESIGN.md §2 (folder structure) and §4 (the /admin contract preamble) are
updated for the new files and their gates. PROJECT_TREE.md is left alone — since
website#98 it is auto-generated by the sync-project-tree workflow.
Co-Authored-By: Claude <noreply@anthropic.com>
Documentation half of the first of five domain-split PRs (API_V2_PLAN.md § Phase 2).
BACKEND_DESIGN.md
- §2 folder structure: admin/ now shows index.js (shared gate + mount table) and
the four capability routers with their route counts, prefixes and extra gates;
admin.routes.js is labelled as the 82-route residual that goes away with PR 5.
- §4 /admin heading: was "admin.routes.js -> admin.controller.js", now points at
admin/index.js and notes staffOnly, which the old heading omitted.
- The "planned change" note becomes "in progress" with what has landed.
API_V2_PLAN.md
- Status: planning -> in progress; PR 1 marked landed in the sequencing list.
- New "PR 1 — as landed" section: the route-count table (6+15+3+4+82 = 110) and
three findings for PRs 2-5 — why the self-service /shard/* routes stay with the
shard capability despite their Admin · Account tag, why a prefix mount must not
be "simplified" to a pathless one (a bare use(gate) would then run for requests
headed to later mounts), and that routes.guards.json came back zero-diff too.
- New section on the swagger path-normalization prerequisite and its consequence:
with sorted path keys, a pure route move produces no spec diff, so the spec
becomes a third zero-diff gate alongside the manifest and guards files.
- Correction to step 6: PROJECT_TREE.md is auto-generated by the sync-project-tree
workflow since website#98 and must not be hand-edited in split PRs.
api-route-inventory.json is unchanged — verified still byte-identical to
server/routes.manifest.json (200 public + 2 internal), which is the point.
Co-Authored-By: Claude <noreply@anthropic.com>
Matches website PR "build(swagger): normalize and sort generated OpenAPI path
keys", which post-processes swagger-autogen's output ahead of the admin router
domain split (API_V2_PLAN.md § Phase 2).
- website-README.md § Regenerating the spec: why trailing slashes are stripped
(a capability router mounted at /users declaring router.get('/') would document
/api/v1/admin/users/, a URL no client calls) and why path keys are sorted.
- BACKEND_DESIGN.md § generated artifacts: note that both the route manifest and
the spec are emitted sorted, so a diff in either is proportional to the change.
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website "feat(security): soak the tightened CSP on report-only".
The plan's Phase 1 claimed a two-directive delta, one of which was adding
`form-action 'self'` as "currently absent". It was not absent. The directives
object in app.js does not list it, but the middleware runs `useDefaults: true`
and helmet's default set already supplies it, so production has been serving it
all along. The plan was written from the config rather than from the live
header; the correction, and how it was caught, are now recorded in place rather
than quietly fixed.
That leaves `frame-ancestors 'self'` -> `'none'` as the entire behavioural delta
of the phase. Worth noting that this is also the directive that most justifies a
soak: a frame-ancestors violation is reported by the browser of whoever framed
the site, so it is the only available way to discover a legitimate embed before
an enforcing policy breaks it.
Also documented:
* POST /api/csp-report -- the same-origin sink report-to/report-uri point at,
why it is same-origin, why it lives outside /api/v1, both wire formats, the
Reporting-Endpoints header requirement, and the properties that make an
unauthenticated public POST safe (always-204, caps, truncation, rate limit).
* That the sink is scoped to the soak, so the enforce PR must decide
explicitly whether to retire it or keep a report-to group on the enforced
policy -- rather than leaving an orphan route behind.
* The `[csp]` log tag in section 7.5 as the thing to watch during the soak,
and what silence across one release means.
* client/vite.config.js already sets `modulePreload: { polyfill: false }`, so
the plan's inline-polyfill prerequisite was already satisfied.
api-route-inventory.json moves 199 -> 200 for the new route. That is the PR 0
freeze working as intended: the first manifest diff since the baseline is a
deliberate, reviewed one.
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website PR 0 (chore(server): freeze the URL surface with a
generated route manifest).
BACKEND_DESIGN.md gains § 4.0, naming the two generated artifacts that are
actually authoritative about the API and what each is authoritative *for*: the
manifest records which URLs exist (introspection-derived, reality), the Swagger
spec records what they mean (annotation-derived, intent). The prose tables in
§ 4 are orientation and can drift; those two files cannot. Also documents
routes.guards.json as a review aid that is explicitly not a contract.
API_V2_PLAN.md marks PR 0 shipped and records its two deviations. The optional
unauthenticated-status snapshot was tried and dropped exactly as that section
allowed — against the dead-port mariadb pool the tests use it sits on the acquire
timeout rather than failing fast — replaced by a deterministic assertion that
every /admin/** and /player/** route still carries requireAuth.
routes.guards.json is committed and staleness-checked even though a diff in it
is not a contract change, because an ungenerated review aid rots into a
misleading one.
The sequencing section is corrected: PR 0 now runs before the CSP pair. The CSP
report-only PR must stand up a POST /api/csp-report collector for `report-to` to
target, which is a new URL under /api/**; landing it first would have left PR 0
generating 200 routes against a 199-route baseline, destroying its own acceptance
test. With PR 0 first, the collector appears as a reviewed, deliberate +1 in the
manifest — the mechanism working as intended.
api-route-inventory.json is unchanged, which is the point: the generator
reproduced it byte-for-byte on first run.
Co-Authored-By: Claude <noreply@anthropic.com>
The API v2 plan is revised down to the work that is actually justified: a CSP
hardening pass and an in-place domain split of the monolithic route wiring.
- Auth merge (httpOnly cookies -> bearer + rotating refresh for every client) is
removed and re-filed as deferred behind trigger conditions. httpOnly+SameSite
is the stronger model, session.service.js already unifies cookie and bearer,
the SSO/PKCE transaction cookies survive any merge, and it dragged the admin
SSE fetch/ReadableStream rewrite along as a dependency for no user-visible
payoff. A revival must first spec refresh-token reuse detection and a rollback
procedure.
- No parallel /api/v2. The URL surface is already grouped by capability, so each
new router file mounts at the prefix it already owns and every URL stays
byte-identical. No dual mount, no per-route migration, no v1 retirement; the
SPA, Discord bot, and Android app are all untouched. API_V2_SKELETON.md is
marked superseded (kept as the recipe if a versioned API is ever forced).
- The /api/mobile facade and app-version floor are deferred with the revival note
that it starts as a one-line alias mount, not ~70 hand-written delegates. The
M11 milestone is dropped from android/PLAN.md.
- Adds PR 0: a generated route manifest, so "every URL is unchanged" is proved by
a zero-line diff rather than asserted in review. The baseline
api-route-inventory.json (199 API routes + 2 internal) is committed here and is
what PR 0's generator must reproduce byte-for-byte.
- Split sequenced as five grouped PRs; CSP fixed to report-only first, then
enforce (the old plan contradicted itself), with the verified delta being just
form-action 'self' and frame-ancestors 'none'.
Co-Authored-By: Claude <noreply@anthropic.com>
Expand the API v2 plan to account for consumers beyond the browser and
insulate the Android app from version churn before the v2 work begins.
- Inventory the three v1 API consumers (browser, Android app, Discord bot)
and map the cross-component contracts (site<->link, site<->mobile).
- Fix two concrete plan bugs: the public shard SSE stream must stay
anonymous (logged-out browsers and the app's ShardStreamClient send no
auth header), and the useShardFeed fetch-rewrite is admin-stream-only.
- Add "Phase 0 - the mobile facade": a version-agnostic /api/mobile
namespace (a thin BFF delegating to current controllers behind pinned
wire shapes), landed before v2 so the auth merge never touches the app.
- Note link/ is essentially out of scope (no PROTOCOL_VERSION bump), with
the admin-stream allowlist split as the only shared seam.
- Resequence PRs (Phase 0 first) and gate v1 retirement on the pre-facade
app fleet aging out via an app-version floor, not the web client.
- Add M11 to docs/android/PLAN.md: migrate the app to /api/mobile + ship
the app-version floor, cross-referenced with the website plan's Phase 0.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Companion doc to API_V2_PLAN.md describing PR 1: stand up router/v2/ empty but
wired next to a frozen /api/v1, with a trivial GET /api/v2/version to make the
mount testable and no behavior change. Includes the file tree, the api.router.js
/ v2.router.js wiring, empty capability-router stubs, and acceptance criteria.
Adds a forward link from the plan's PR-1 line to the skeleton doc.
Co-Authored-By: Claude <noreply@anthropic.com>
Plan for website API v2, sequenced in two phases behind a parallel /api/v2:
- Phase 1: retire httpOnly session cookies; unify web + mobile on the existing
bearer access + rotating/revocable refresh model. Separates removable session
cookies from the SSO/email transaction cookies that must stay. SSE moves to
fetch-based streaming with Authorization: Bearer.
- Phase 1b: tighten the shipped CSP for the now-JS-held token (add form-action
'self', frame-ancestors 'none'); self-host fonts + Trusted Types as follow-ups.
- Phase 2: break the monolithic route wiring (admin.routes.js, ~100 routes) into
one router per business capability so the URL predicts the file.
Co-Authored-By: Claude <noreply@anthropic.com>
Add auto-generated project-tree snapshots for the website, link, and
Android-app repos under docs/<repo>/PROJECT_TREE.md, and link them from
the README (adding a previously-missing android/ section). These files
are maintained going forward by the sync-project-tree CI workflow in each
source repo, which opens a PR here whenever the tracked layout changes.
Co-Authored-By: Claude <noreply@anthropic.com>
Match the website change: the ntfy relay is reached through the public
reverse proxy, which runs outside the compose network and can only reach
a service via a published host port. Update the "no published host port /
internal-only publisher" claims in android/PLAN.md (§11 + §13) and
website/BACKEND_DESIGN.md to describe the published NTFY_HOST_PORT
(default 2586 -> ntfy:80), and note that both devices and the backend
publisher reach ntfy on the public origin.
Co-Authored-By: Claude <noreply@anthropic.com>
Record that the whole /player/* router sits behind requireAuth only (not
requireRole('player')): staff are a superset of players, every handler is
self-scoped to the caller, and staff reach the identical handlers under
/admin/shard/*. This is why a staff account with linked characters gets
its "My characters" and personal notification streams on the mobile
client. Matches the code change in RunicGateway/website.
Co-Authored-By: Claude <noreply@anthropic.com>
Add TRUSTED_DEVICES_MFA.md (the approved design/implementation plan) and fold
the feature into BACKEND_DESIGN §3 (trusted_devices + recovery_codes schema),
§4 (login/totp trust+recovery, /auth/me/trusted-devices*, recovery-codes*,
admin trusted-device + /mfa/reset routes), and §6 (trusted-device security
model + audit actions). Note the app-side trust/recovery flow in android PLAN §4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/website/ARCHITECTURE.md (canonical copy of the website architecture
Mermaid diagram, with fuller notes) and embed the same diagram in the
website-README mirror. Mirrors the diagram added to the website repo README.
Co-Authored-By: Claude <noreply@anthropic.com>
Document the test plan for the bot/ workspace — the last of the three website
npm workspaces without a suite (server + client landed in website PR #86).
Records the shared node --test conventions (no jest/vitest/jsdom; DB pool at a
dead port; Discord objects hand-faked) and maps the meaningful bot behavior to
lock: the normalize/duration/spam pure logic, the invite/site-api/internal-key
single-collaborator units, the messageFilter pipeline (bypass-first decision
order + fixed-duration mute + best-effort recording), and the models with real
shaping logic. Includes the CI + SonarQube coverage wiring to mirror PR #86 and
a suggested phasing.
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the vague "helmet with a CSP suited to the SPA" line with the actual
policy now implemented in server/src/app.js: per-directive sources and the
rationale for each non-'self' allowance (Google Fonts, inline React styles,
external/embedded images, same-origin REST+SSE), why upgrade-insecure-requests
is omitted, the scoped looser CSP for the /api/docs Swagger UI route, and the
X-Powered-By handling across the public and internal listeners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Promote APP_LINKS.md from a deferred design note into an implementation spec
matching the website `feat/mobile-app-links` and android `feat/app-links`
branches: the server-side `/.well-known/assetlinks.json` route + `mobile_app_links_enabled`
toggle + additive redirect-allowlist entry, and the app-side `autoVerify`
intent-filter driven by a build-time `appLinkHost` (a single multi-tenant APK
cannot autoVerify open-ended shard domains, so App Links are a white-label /
first-party build opt-in; the custom scheme stays the permanent fallback).
Update PLAN.md §9 (M9 follow-up) and the §14 open item, and the redirect-URI
allowlist section of website/BACKEND_DESIGN.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Record the plan before coding: native Android "Sign in with Google/Discord"
via a Mobile SSO Authorization Bridge that extends the existing /auth/sso/*
redirect flow and terminates in the existing mobile bearer tokens.
- BACKEND_DESIGN.md: mobile_auth_sessions / mobile_auth_codes schema, the
/auth/mobile/sso/{start,exchange} contract, the two PKCE layers, state/CSRF,
exact-match redirect-URI allowlist, TOTP parity, and the documented
revocation-latency window.
- android/PLAN.md: promote §4.2's "possible later enhancement" to milestone M9
(backend-first, mirroring M7); status note.
- android/APP_LINKS.md: new architecture note on the per-shard assetlinks.json
/ pairing multi-tenancy question (App Links deferred; custom scheme only now).
Co-Authored-By: Claude <noreply@anthropic.com>
Flip M7 Part 2 to landed (Android-app#15) and record the two implementation
decisions: the direct-ntfy embedded distributor (no UnifiedPush library — the
plan's stated likely path; foreground-service SSE, no second app, no Google
Play Services), and the small additive push.ntfyUrl settings field the app
needs to discover the relay (website#79). Document push.ntfyUrl +
NTFY_PUBLIC_URL in BACKEND_DESIGN.md.
Co-Authored-By: Claude <noreply@anthropic.com>
- BACKEND_DESIGN.md: push_devices + notification_subscriptions tables (§3), the
/auth/me/devices* + /auth/me/notifications/* API rows (§4), a push-notification
design + security section (content-free tickles, PUBLIC_KINDS split, owner-keyed
personal streams, SSRF endpoint guard, untrusted-relay model), and the ntfy
compose service in the deploy section (§8).
- PLAN.md: flip M7 Part 1 (backend + docs) to in-review — status line, §8 item 3,
§9 M7.
Co-Authored-By: Claude <noreply@anthropic.com>
Counterpart to RunicGateway/website#77.
- BACKEND_DESIGN.md: /public table now documents the new GET /public/version
(DB-free identity/version), the version block on /public/status, and the brand
block on /public/settings (per-shard theming: name/accent/logo/hero/favicon).
- android/PLAN.md: mark §8 item 4 (version/health) and item 6 (branding) DONE and
update the prerequisite-progress summary — all v1 prerequisites are now done;
only push notifications (item 3) remains and is post-v1 (M7). App M0–M4 unblocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Counterpart to RunicGateway/website#76 (role-agnostic /auth/me/* self surface).
- BACKEND_DESIGN.md: add the /auth/me/account* rows to the /auth API contract and
a note that the surface reuses account.controller behind requireAuth (any role),
so a client manages its own account without touching /admin.
- android/PLAN.md: mark §8 item 1 (role-agnostic self-service) DONE and update the
prerequisite-progress summary; version/health (item 4) and branding (item 6)
remain open, push (item 3) is post-v1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Add /auth/password/forgot and /auth/password/reset/:token to the API
contract and the password_resets table to the schema section, matching the
website change (RunicGateway/website feat/password-reset). Notes the
no-enumeration behaviour, single-use hashed-token model, and that the Android
app hands off to the web reset page (PLAN.md §4.2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
The "What each audience sees" table said the public "Staff online" list
is shown "with name + map location". Location is now privileged: the
server includes map/coords only for admin/moderator callers and strips
them from the payload for players and the public. Update the wording to
match (RunicGateway/website#72).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Sync the extracted website README with the current repo README after the
org rename + BRAND_* branding work: UOMysticmoon → Runic Gateway as the
default identity (UOMysticmoon retained only as the example first instance),
UOM/link → RunicGateway/link, DB/cookie/TOTP defaults updated, and the new
Branding section added. Doc-local BACKEND_DESIGN.md links kept relative;
the .env.uomysticmoon.example link points at the website repo where it lives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
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
Final phase of the hero canvas editor (see HERO_EDITOR.md) — v1 complete.
- element tray adds moon, badge, and image; property panels:
- moon: size / glow / color
- badge: text / background / text color / corner radius
- image: upload (/admin/uploads, >1MB warning) / width% / alt
- corner resize handle on selected elements (image→width%, moon→size,
text_block→box width)
- 8px snap-grid toggle with a faint canvas grid overlay; drag snaps when on
- HeroElement: image element shows an "Upload an image" placeholder until a
source is set (a srcless image never ships live)
Verified in-browser: all five element types add + edit; moon resized 64->104px
via the handle; snap grid overlays; a published moon + badge render on the live
portal; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third phase of the hero canvas editor (see HERO_EDITOR.md).
- HeroElement: editor mode — inner content made non-interactive so the wrapper
handles select/drag; selection outline; box width now canvas-relative
(calc(100% - 36px)) so text blocks fit the smaller editor canvas
- HeroEditor: element tray (+ Text / + Buttons), click-to-select, native
Pointer Events drag (position as % of the canvas, clamped), Delete key + panel
delete, z-order (send back / bring forward), and per-type property panels:
- text_block: per-line text / tag / font size (px) / color / bold, add+remove
lines, alignment
- buttons: per-item label / path / variant, add+remove, alignment
empty-canvas click deselects (back to the background panel)
- theme.css: .hero-el-editable outline/hover/selected + grid helper
Verified in-browser: selecting shows the line editor, editing updates the canvas
live, drag repositions, add/delete and z-order work, deselect returns to the
background panel; no console errors. Moon/badge/image + resize + snap are Phase 4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second phase of the hero canvas editor (see HERO_EDITOR.md).
- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
- live canvas preview (16:9) rendering the draft via HeroElement
- background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
grid, overlay opacity slider — all update the canvas in real time
- debounced (800ms) auto-save to hero_layout_draft
- Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
"showing unpublished draft" banner; normal load renders the published layout
No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build contract for the WYSIWYG portal-hero editor on hero-feature, derived
from the design doc and corrected against the codebase:
- public settings is a whitelist (getPublic/PUBLIC_KEYS), so hero_layout must
be added there — the doc's "no backend changes" was wrong
- moon is the reusable MoonDot component; route vs nav live in App.jsx vs
AdminLayout.jsx; admin content is 1000px (canvas scales to fit)
Locked decisions: full v1, buttons as a first-class element type, pre-populate
the current hero on first run, native Pointer Events for drag. Phased plan
with per-phase exit checks. No schema change (JSON in settings).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final phase of the wiki upgrade (see WIKI_UPGRADE.md).
Schema (additive): wiki_revisions table (per-save content snapshots).
The FULLTEXT index on wiki_pages(title, body) shipped in Phase 1.
Search:
- MATCH ... AGAINST natural-language search over title + body, ordered by
relevance
- public: GET /public/wiki?q= (published only); admin: GET /admin/wiki?q=
(all statuses)
- public wiki index gains a search box; admin list gains a search field
Revision history:
- every create/update snapshots the page into wiki_revisions
- admin endpoints: list revisions, get one, and restore (restore overwrites
the page, rebuilds links, and appends a new revision — history stays
append-only); logged as wiki.revision.restore
- editor gains a History modal: revision list + word-level diff (jsdiff) of a
chosen revision against the current page, with one-click restore
Verified end-to-end: search matches body and title; two edits produce three
revisions; diff renders added/removed words; restore reverts and records a new
revision. No console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connectivity phase of the wiki upgrade (see WIKI_UPGRADE.md).
Schema (additive new tables): wiki_tags, wiki_page_tags, wiki_links.
Internal links & backlinks:
- new wiki.links.js parses a saved body for /wiki/<slug> (and data-wiki-slug)
targets; wiki_links is rebuilt on every save
- article shows a "Linked from" section (published backlinks) and renders
links to non-existent pages as red links (server returns missing_links)
- editor gains an internal-link picker listing existing pages
Tags:
- pages accept a tags[] array; tags upsert on save, page tag-set is replaced,
and orphaned tags are auto-pruned (on save and delete)
- public/admin list filter by ?tag=; /wiki/tags lists tags with published counts
- article shows tag chips; the index has a flat tag-filtered view; editor has a
comma-separated tags field
Verified end-to-end: A->B backlink appears, red link detected, link index
rebuilds on edit, tag filtering + chips + pruning all work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the raw-HTML textarea in the wiki editor with a TipTap (ProseMirror)
WYSIWYG editor.
- new RichTextEditor component: bold/italic/strike, H2/H3, bullet+ordered
lists, blockquote, code block, divider, link, inline image, undo/redo
- generalized POST /admin/uploads (reuses the screenshot multer config) →
{ url }; the editor uploads inline images through it
- editor output still passes through the Phase 1 server-side sanitizer on
save and DOMPurify on render
- lazy-loaded as its own chunk so the public bundle doesn't ship TipTap
(public ~82kB gzip; editor chunk ~106kB gzip loaded only in admin)
- RTE styling added to theme.css (toolbar, active states, prose content)
Verified: uploads serve as images; H2/H3 + lists + link + inline image
round-trip through the WYSIWYG and render sanitized on the public page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md).
Schema (additive, idempotent via ensureSchema):
- new wiki_categories table; wiki_pages gains category_id, excerpt,
published, published_at, sort_order, and a FULLTEXT index
- migration ALTERs guarded with IF NOT EXISTS for existing databases
- seed reworked into 4 sections with the 8 starter pages assigned
Security:
- new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are
sanitized on every save, and the article renders through DOMPurify
- strips <script>, event handlers (onerror), and javascript: URLs
Backend:
- public: published-only list with ?category filter + /wiki/categories
- admin: extended page CRUD, PATCH publish toggle, category CRUD;
drafts visible to admin, hidden from public
- all writes logged to activity_log
Frontend:
- data-driven public wiki index (sections + real descriptions; removed
hardcoded blurbs/Roman numerals) with ?category filtering
- article: category breadcrumb + sanitized render
- admin: Section/Status columns, draft/publish + section + excerpt in the
editor, and a Manage sections modal
Verified end-to-end against MariaDB 11: migration clean, XSS neutralized,
drafts hidden, client builds, server boots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>