Files
mathew/AGENTS.md
2026-09-30 01:49:35 +02:00

9.4 KiB

Mathew — a wiki for mathematics

A collaborative wiki of math concepts. Articles are written in Markdown with LaTeX (KaTeX) and stored in MongoDB behind an Express API; a Nuxt 4 app renders and edits them.

mathew/
├── dev.sh          # starts/stops both dev servers together
├── backend/        Express 5 + Mongoose API (port 5000)
└── frontend/       Nuxt 4 / Vue 3 wiki UI (port 3000)

Running the project

Requires MongoDB on localhost:27017 (database mathew).

./dev.sh                 # both dev servers in foreground (Ctrl+C stops both)
./dev.sh start|stop|restart|status
./dev.sh logs [all|backend|frontend]

Or per-service: npm run dev inside backend/ or frontend/ (backend uses node --watch; backend has no build step).

Seed sample articles: npm run seed in backend/.

Environment: backend/.env (PORT=5000, MONGO_URI=mongodb://127.0.0.1:27017/mathew, JWT_SECRET — signs session tokens, AUTH_TOKEN_TTL — how long one lasts, default 7d; see .env.example). Without JWT_SECRET the API warns and uses an insecure dev key. Frontend API URL comes from NUXT_PUBLIC_API_BASE (default http://localhost:5000, read via runtimeConfig.public.apiBase).

There is no test suite, linter, or typecheck script configured — don't invent commands.

Backend (backend/)

Plain JavaScript, ESM ("type": "module", .js files with relative .js import specifiers).

  • src/index.js — app setup: CORS, JSON body, /health, routers, 404 handler, central error handler (Mongoose ValidationError/CastError → 400), graceful shutdown. After the connection it calls User.ensureAnAdmin() so somebody can always open the dashboard.
  • src/config/db.js — connectDB / disconnectDB.
  • src/models/ — Mongoose schemas with timestamps: true. Article (slug, title, summary, content, tags + text index), Item (simple demo resource), User (username + passwordHash, select: false; setPassword/checkPassword via bcrypt, toPublic() for responses, and credentialProblem() as the single place account rules are checked). The profile picture lives on User.avatar as a small data URL — no file storage. User.role is 'admin' or 'member' (ROLES); User.hasNone() and User.ensureAnAdmin() cover who gets the former — see the roles gotcha below.
  • src/routes/ — one Express Router per resource, mounted in index.js.
  • src/routes/auth.js — POST /api/auth/register (creates the account and returns its session; the wiki's first account is created as an admin), POST /api/auth/login, GET /api/auth/me (who a token belongs to). Login answers the same way to a wrong name and a wrong password. Account updates, all behind requireAuth: PUT /api/auth/me (rename), PUT /api/auth/password (needs the current one), PUT /api/auth/avatar (data URL, "" clears).
  • src/routes/admin.js — mounted at /api/admin, everything behind requireAuth, requireAdmin: GET /api/admin/users (every account, newest first, with createdAt), PUT /api/admin/users/:id/role (role is 'admin' or 'member'; an admin's own role is the one they cannot change), and DELETE /api/admin/users/:id (closes an account for good — never their own, by the same rule; the articles it wrote stay, since articles name no author).
  • src/middleware/auth.js — signToken(user), requireAuth, which reads Authorization: Bearer <token> and sets req.user = { id, username }; anything else is answered with 401 { message }. requireAdmin runs after it and reads the role off the account (403 { message } for a member), so removing the role works the next time the dashboard is asked for.
  • src/seed.js — sample articles.

Conventions to follow:

  • Every handler is async with try { ... } catch (err) { next(err); }; the central error handler in index.js formats responses ({ message } everywhere).
  • Articles are addressed by slug, not by Mongo id (/api/articles/slug/:slug). slugify() / uniqueSlug() (in routes/articles.js) generate uniquified slugs (-2, -3, ... suffixes); POST requires title, PUT re-slugs when the title changes.
  • The list endpoint (GET /api/articles) selects -content (no body) and supports ?q= (regex search on title/summary/tags) and ?tag=.
  • Reads are public; the writes (POST /api/articles, PUT/DELETE by slug) run through requireAuth.

Frontend (frontend/)

Nuxt 4 (srcDir app/, TypeScript, Vue 3 <script setup> SFCs). No UI framework — hand-rolled SCSS (devDep sass) in app/assets/css/: main.scss @uses one partial per section (_tokens, _layout, _editor, _book, …); shared shapes (popups, dropdowns, segmented controls, avatars) are mixins in _mixins.scss, and the accent themes generate from a map in _tokens.scss. Colours stay CSS custom properties so themes swap at runtime via data-theme/data-accent attributes (set pre-paint by an inline script in nuxt.config.ts).

  • app/pages/ — / (search), /wiki/[slug] (article + TOC), /wiki/[slug]/edit, /new, /admin (the dashboard: every account and its role, plus the buttons that hand the role on or take it back — an admin alone sees anything but a refusal here). The two editor routes wrap their form in AuthGate, so it shows only with a session.
  • app/components/ — PascalCase SFCs (header, editor, MarkdownView, TocNav, etc.). AuthGate.vue is the editor's stand-in for a signed-out visitor; AuthModal.vue is the popup that signs them in; ProfileModal.vue is the account popup the sidebar's account row opens (picture, username, password, sign out). ConfirmModal.vue is the yes/no popup any destructive step asks through (the editor's discard, the dashboard's delete). All popups (teleported overlays, closed by esc/backdrop/ navigation like the other popups — opening one calls hide() on the others, they never stack). A row of actions folds into a ⋮ menu built on the shared .kebab* styles: ArticleActionsMenu.vue on an article's title row, AccountActionsMenu.vue on a row of the dashboard (the role and the account).
  • app/composables/ — useTopics (topics = distinct tags computed client-side from the full article list, shared via useState), useRecentlyViewed (localStorage), useAuth (the session: token in the mathew-session cookie so the server sees it too, signIn/signUp/signOut, authHeaders() for write calls, restoreSession() asking /auth/me who a stored token belongs to, plus updateUsername/ changePassword/setAvatar for the profile popup, and isAdmin — whether the account holds the admin role, which is what the sidebar's Admin link and the dashboard are drawn from), useSignInPopup (open state of that popup, shaped like useSettings/useSearch), useProfileMenu (same, for the profile popup), useArticleActionsMenu (same, for an article's ⋮ menu), and useAccountActionsMenu (same, for a dashboard row's ⋮ — it holds the id of the row that is open, so one row's menu is open at a time rather than a boolean per row).
  • app/plugins/auth.ts — runs restoreSession() once at startup on server and client, so the sidebar's account row and the editor's gate are right in the first render.
  • app/utils/markdown.ts — the single render pipeline: markdown-it (html: false, raw HTML is escaped) + markdown-it-texmath + KaTeX ($...$ inline, $$...$$ display, macros \R \N \Z \Q \C) + highlight.js, sanitized with DOMPurify. It also builds the TOC. Render all article Markdown through this, not ad-hoc renderers.
  • API calls use $fetch against ${useRuntimeConfig().public.apiBase}/api.

Gotchas

  • Reading is open; writing takes an account. POST /api/articles and PUT/DELETE by slug need Authorization: Bearer <token> from /api/auth/login or /api/auth/register. The frontend keeps that token in the mathew-session cookie (readable by the app, sent as a header — it is not an httpOnly session cookie), so a set JWT_SECRET matters in production; anyone with the dev key can mint a session.
  • A 401 from a write means the held session is dead; the editor keeps the draft and opens the sign-in popup over it rather than signing out and unmounting the form.
  • Anyone may register, and an account cannot edit another's article (articles store no author). Roles are the one difference between accounts: role is 'admin' or 'member'. The first account ever registered is the admin — and on a wiki whose accounts all predate the role, User.ensureAnAdmin() gives it to the oldest one at startup. Admins promote and demote from /admin; nobody may change their own role, so there is always at least one admin left to open the page. The same menu deletes an account — through ConfirmModal, and never the admin's own, for the same reason. Articles outlive the account that wrote them.
  • Profile pictures are stored as data URLs on the account (the browser shrinks them to a 256px square before upload); express.json allows 1.5 MB bodies so one fits a request. A 401 from a profile update means the session died — the popup signs out and opens the sign-in popup over it.
  • Changing an article's title can change its slug; old URLs 404 (no redirects).
  • Frontend dev server may fall back to port 3001 if 3000 is taken.
  • frontend/types/markdown-it-texmath.d.ts provides types for the untyped texmath package.