105 KiB
Wiki.js 3.x
Next-generation open source wiki. This is the 3.x development branch — incomplete, unstable, and with no upgrade path from 2.x. AGPL-3.0.
Nothing here has to stay compatible with an existing installation. Nobody is expected to be
running an earlier state of this branch, so do not write migration shims, legacy-value fallbacks,
deprecated aliases or "old data may still contain X" handling. Change the shape, change the callers,
and delete the old path — a fallback for a case that cannot occur is dead code that still has to be
read, tested and reasoned about. This applies to db columns, API payloads, stored settings and
config keys alike; only real migrations under backend/db/migrations/ are exempt, because Drizzle
needs the history to get a live dev database to the current schema.
Three independently-installed workspaces (each has its own package.json / node_modules, there is
no root package or monorepo tooling):
| Path | What it is |
|---|---|
backend/ |
Fastify REST API server + job scheduler, Drizzle on PostgreSQL |
frontend/ |
Vue 3 / Vite SPA, Tailwind CSS + an in-repo component library |
blocks/ |
Lit web components users embed into wiki pages |
Requires Node.js 26+ and PostgreSQL 16+. All three workspaces are ESM ("type": "module").
The backend is TypeScript 7; frontend/ and blocks/ are JavaScript. See
TypeScript (backend).
Layout
Root
config.yml— instance config (copy ofconfig.sample.yml). Read by the backend at boot and byfrontend/vite.config.jsin dev mode to learn the proxy target port.assets/— build output of the frontend (vite buildwrites here), plus static assets underassets/_assets/. Served by the backend. Don't hand-edit.dev/— deployment/packaging artifacts:dev/build/Dockerfile(production image),dev/helm/,dev/packer/,dev/noto-emoji-build/..devcontainer/— VS Code dev container (app + postgres + pgAdmin + mailpit via docker-compose). Mailpit is the mail server for development: it accepts everything and delivers nothing, so a confirmation link or a password reset lands in a web inbox athttp://localhost:8025rather than a real mailbox. Point the wiki at it under Admin → Mail — hostlocalhost, port 1025, TLS off, no credentials.- Locale strings live in
backend/locales/.
backend/
Entry point is backend/index.ts, and it must be run from the repo root (node backend), not
from inside backend/. It boots in three phases: preBoot() (config → db → models → cache →
scheduler → event emitters), initHTTPServer() (Fastify plugins, auth, routes), postBoot()
(refresh locales/strategies/sites from disk & db, start scheduler).
Started with --no-experimental-webstorage — by the npm scripts and by the production image's
CMD, which is the only reason a bare node backend still opens with an experimental warning about
localStorage. Nothing here uses Web Storage; lib0, under yjs, probes for it as it loads the way a
library that runs in a browser too has to, and Node 26 answers that probe with a warning instead of a
value unless --localstorage-file is given. Off, the global is absent and the probe takes its node
path in silence.
api/— REST route plugins, one file per resource (sites.ts,users.ts,pages.ts,system.ts,locales.ts,authentication.ts), registered byapi/index.tsunder the/_apiprefix.api/schemas/— shared JSON Schemas registered viaapp.addSchema()and referenced from route schemas as{ $ref: 'Site#' }. Register new shared schemas inapi/index.tsbefore the routes.
controllers/— non-API HTTP routes.site.tsserves per-site resources (logo, favicon, login background) under/_site;icons.tsserves icons under/_icons, implementing the part of the Iconify API protocol the frontend speaks (/_icons/<prefix>.json?icons=a,band/_icons/<prefix>/<name>.svg). Public and cached hard — see Icons.blocks.tsserves compiled blocks under/_blocks, from the built tree or from an imported package depending on the block and the site — see Distributing a block.rootFiles.tsis the exception that registers at the root rather than under a prefix:robots.txtandsitemap.xml, the two names inRESERVED_ROOT_FILESa crawler asks for by convention, both driven by the site's General → SEO settings. The sitemap lists what the GUESTS group may read and nothing else.core/— long-lived singletons:config.ts(yml + db-backed settings),db.ts(pg pool, Drizzle instance, migrations, LISTEN/NOTIFY pubsub),logger.ts,scheduler.ts(poolifier thread pool + postgres-backed job queue).db/—schema.ts(all Drizzle table definitions),relations.ts,migrations/(generated).models/— data-access classes over Drizzle, aggregated bymodels/index.tsand exposed asWIKI.models.*. Business logic belongs here, not in route handlers.types.tsholds the sharedSystemIdspassed to each model'sinit()during first-run seeding.modules/— pluggable extensions, discovered from disk. Each module is a directory with adefinition.yml(key, title, props/config schema) plus its implementation — e.g.modules/authentication/local/.modules/storage/*shipsdbanddisk— see Storage targets.modules/analytics/*is the odd one out: a pair of YAML files and no implementation at all — see Analytics.tasks/simple/— jobs run in-process by the scheduler; each exportstask(). File name is kebab-case, the task key is its camelCase form.tasks/workers/— CPU-bound jobs run in a worker thread viaworker.ts, which boots a minimalWIKIglobal (config + logger + lazyensureDb()) and dynamically imports the task.base.yml— system defaults for every config key. Do not edit as a user-facing config; it defines the shape merged withconfig.ymland the dbsettingstable.helpers/— small pure utilities (common.ts,config.ts), plus two that are not:storageFiles.ts, the file-tree half of the storage modules that address content by path (see Storage targets), andappShell.ts, which describes a page in the HTML document served for it (see The app shell and SEO).types/— ambient declarations:global.d.ts(theWIKIglobal) andfastify.d.ts(session + route-permission augmentations).locales/—en.jsonsource strings (CrowdIn-managed) +metadata.jslanguage table (the one remaining JavaScript file; typed by its siblingmetadata.d.ts).
frontend/
Vue 3 on plain Vite. src/main.js wires it up manually: router → pinia store → boot/*
initializers → mount. There is no UI framework: src/components/shared/ is the component library
(every component is W*, used in templates as <w-btn>, <w-input>, …), registered globally by
boot/components.js and styled with Tailwind.
src/boot/— one-time app initializers:api.js(creates thekyclient, exposed as theAPI_CLIENTglobal),components.js(global components),eventbus.js(EVENT_BUSglobal, mitt),externals.js,i18n.js,iconify.js(points Iconify at this instance's/_icons),monaco.js,temporal.js(conditionally polyfillsTemporal, awaited before anything else inmain.js).src/router/—index.js(router factory) androutes.js(the full route table; page components are lazily imported).src/layouts/—MainLayout,AdminLayout,AuthLayout,ProfileLayout.src/pages/— route-level views.Admin*.vueare the admin area,Profile*.vuethe user profile.src/components/— everything else: dialogs (*Dialog.vue), full-screen overlays (*Overlay.vue), editors (Editor*.vue), nav/tree components.src/stores/— Pinia stores (site,user,page,editor,admin,common,flags).stores/index.jscreates the pinia instance and injectsrouterinto every store.src/renderers/— page content rendering pipeline:markdown.jsplusmodules/(katex, kroki, plantuml, markdown-it plugins).src/css/—tailwind.css(theme tokens, utilities and the shared component classes) plus SCSS:_theme.scss(brand colours) and_palette.scss(the Material ramp the older stylesheets use). Both are injected into every SFC bycss.preprocessorOptions.scss.additionalDatainvite.config.js, which is why templates can write bare$primary/$grey-4.src/helpers/,src/assets/,public/,index.html.
Path alias @ → frontend/src (defined in vite.config.js; jsconfig.json mirrors it for the IDE).
Dev server runs on 3001 and proxies /_api, /_blocks, /_icons, /_site, /_thumb, /_user
to the backend on 3000, so the backend must be running too.
blocks/
Self-contained Lit components. Each lives in blocks/block-<name>/component.js — the glob in
rollup.config.mjs picks up any directory matching block-* automatically, so a new block needs no
config change. Output goes to blocks/compiled/, which the backend serves under /_blocks/ —
alongside the blocks a site has imported as packages, see Distributing a block.
Blocks are loaded dynamically at runtime, which is why _blocks/** is excluded from
Vite's dynamicImportVarsOptions. A block pulling in a heavy library is fine — nothing is fetched
until its tag turns up in a page — and a library that still ships CommonJS works too, since the
rollup config runs @rollup/plugin-commonjs after resolve().
Blocks style themselves off :host and read the theme colors via CSS custom properties
(var(--q-primary) — the --q- prefix is historical; the properties are declared in
css/tailwind.css and rewritten at runtime for per-site theming).
Except where the block's content IS the thing being styled, which is block-tab and
block-steps: both leave what they hold in the light DOM, because it is page content and has to be
drawn by the article's own stylesheet. A shadow root cannot reach into it either — ::slotted()
matches the slotted element and nothing below it, so a slotted <ol>'s <li> items are already out
of range — so their appearance lives in frontend/src/css/_page-contents.scss with the rest of the
content typography, and the component itself is a plain HTMLElement carrying the definition. Don't
copy that shape for a block that draws its own furniture; a shadow root is still the default.
Dark mode goes through blocks/shared/theme.js, never :host-context(). The app's source of
truth is the body--dark class on <body>, which CSS in a shadow root cannot see; :host-context()
is the selector for exactly that and is what every block used to use, but only Chromium ever shipped
it — MDN has it deprecated, Firefox and Safari never implemented it, and there it silently never
matches, so the block stayed light on a dark page. Instead construct a DarkMode controller
(this._darkMode = new DarkMode(this)) in the block's constructor and write :host([dark]); the
controller keeps that attribute in step, sharing one MutationObserver across every block on the page.
A block that must act on the change rather than restyle for it passes onChange, or reads
.isDark — block-diagram redraws mermaid in its own dark theme, block-map resolves a per-block
theme prop that can pin a map light on a dark page.
Distributing a block
A block need not ship with the wiki. npm run package -- block-xyz compiles one block on its own and
writes blocks/packages/block-xyz.wkblock, a single file an administrator uploads under
Admin → Content Blocks → Install Block.... So the whole of writing one is: clone this repository, add
a directory under blocks/, package it, upload it — nothing of the instance is rebuilt or restarted,
and the author never touches the wiki they are writing for.
A packaged block is the same thing as a built-in one, arriving by a different road. Same
component.js, same static definition, same rollup build; what differs is where its files end up
and where its definition is read from. So a block being written is developed in a full checkout —
npm run build and the compiled tree — and packaged once it works. There is no second authoring API.
blocks/package.mjs is the packager and backend/helpers/wkblock.ts reads what it writes. The
format itself is specified in dev/specs/wkblock.md, and that document is the contract: blocks/
and backend/ are separately installed workspaces and the backend does not type-check JavaScript, so
there is no module the two halves could share and nothing that checks one against the other. The two
files are independent implementations of the spec and carry only the rationale for how each is
written — so a change to the format is a change to the spec first, then both files, in one commit.
- One block per package, and the directory name is the identity.
blocks/block-xyz/declaresblock: 'xyz', is packaged asblock-xyz.wkblock, serves asblock-xyz.jsand renders as<block-xyz>. The packager refuses a mismatch, because every one of those names is derived from the same key. A child block (isChild) is refused outright: it is part of whatever holds it, has no row and nothing to switch on, so a package of one would install nothing. - Everything is namespaced under the block's own name, which is what lets an imported block and a
built-in one be served from the same
/_blocks/without either standing on the other. A package holdsblock-xyz.js, optionallyblock-xyz.worker.js, andblock-xyz/**— the assets from itsassets.jsonand, unlike the full build, its shared chunks, whichbuildConfig({ only })names into that directory rather than leaving at the root. Both the packager and the importer check it. - The package is the only copy. It is stored verbatim on the block's row (
packageData) with its definition and achecksum. Nothing is written toblocks/on the server, which is a build output and in a container is part of the image. - Re-importing the same key is an upgrade, not a second block. The row is updated, so what the
site had switched on and configured on it survives; the reply says
isNew: false. A key a built-in block already uses is refused with 409 rather than shadowing it. manage:sitesis what it takes, the same permission the screen already needs — and deliberately not something stricter. A block is code that runs in every reader's browser on that site, which is exactly what the raw head and body fields under Admin → Theme already are. It is not a new kind of power, it is a tidier way to exercise one the admin area already grants.- The container is parsed as something a stranger uploaded, because the trust boundary above is about the CODE, not about the file: every length is bounded before it is acted on, every file's SHA-256 is checked, and every path has to fall inside the block's namespace.
The files are served from /_blocks/ like a built-in's, and controllers/blocks.ts is what decides
which. That route replaced the @fastify/static registration for /_blocks/, because a static
plugin claims the whole prefix and leaves nothing to ask the question in front of it; the plugin is
still registered with serve: false, for reply.sendFile.
- The first path segment names the block, and that is the whole decision — which is the reason the namespace above is enforced.
- It depends on which SITE was asked, since a custom block belongs to one, and two sites on an
instance may each have imported a different block under the same key. The frontend has no site in
hand when it loads a block (it reads a tag out of the page and asks for it), so the hostname
resolves it, the same
WIKI.sitesMappingslookup the request hooks do. <dataPath>/cache/blocks/<siteId>/block-<key>/is a cache, not storage.servingPathForunpacks the stored package into it on the first request andblock-<key>.checksumbeside it says which version is there — written last, so an unpack that died halfway is done again rather than half served. Which also means an upgrade reaches every instance of an HA set on its own, including one that was not running when the upload happened, and a fresh container needs nothing restored.- Custom files are revalidated (
no-cache+ ETag), built-ins are held for an hour. The names are the same across versions of a custom block, and the point of uploading a fixed one is that the fix is live.
A custom block's definition is read from its row wherever a built-in's is read from the manifest —
its props in getSiteBlocks, and its tag and attributes in the sanitiser's allow list
(getEnabledForRender, which fetches both in the one query postProcess was already making, for the
same reason that query is not cached: a definition this misses is a block stripped out of somebody's
page). Everything else about it is identical, the enable toggle included: a custom block that is
switched off is stripped from a page being saved exactly as a built-in one is.
Commands
Run backend commands from backend/, frontend from frontend/, blocks from blocks/.
# backend
npm run dev # nodemon, restarts on any backend file change
npm run start # plain node
npm run typecheck # tsc — type check only, never emits
npm run typecheck:watch
npm run db-generate -- --name=foo-column # drizzle-kit generate — after editing db/schema.ts
npm run db-up # drizzle-kit up
# frontend
npm run dev # vite dev server on :3001 (needs backend running on :3000)
npm run build # builds into ../assets — required before the backend can serve the UI
# blocks
npm run build # rollup → blocks/compiled/
npm run package -- block-x # compile one block → blocks/packages/block-x.wkblock
npx ncu -i (npm run ncu) for interactive dependency updates.
The API is browsable via Swagger UI at http://localhost:3000/_api in a running instance. Default
admin login is admin@example.com / 12345678.
How far to go verifying a change
Match the check to the size of the change. npm run build, npx oxlint and npm run typecheck are
seconds each and are the right check for nearly everything.
Do not stand up a throwaway instance and drive a headless browser to look at a small change. That
means booting a backend against a scratch database, seeding it, and screenshotting through
/usr/bin/chromium — a good ten minutes of setup that a moved border, a colour, a spacing tweak or a
renamed label does not earn. Read the rule you wrote, trust the build, and say what you changed.
It is worth the setup for a new piece of UI whose markup has to meet a stylesheet written elsewhere, where being wrong means shipping something visibly broken — a component reusing existing content classes is the case that has actually gone wrong. Also for a flow with real state to exercise (a login, an upload, a save), where a screenshot answers a question reading cannot.
Booting a throwaway instance
For the cases above, and never against a running dev instance: that database is somebody's own work, and its admin account may well have 2FA on, which cannot be scripted.
A database of its own, not a schema of its own. Copy config.yml to config.test.yml with
port: 3010, db.db: wikitest and dataPath: ./data-test — and leave schema: wiki alone. A second
schema in the same database fails on the first migration: CREATE TYPE "treeType" in
db/migrations/20260809235619_init is not schema-qualified, and neither is the column that references
it, so the type is created in one search path and looked for in another (42704 typenameType). There
is no psql in the dev container, so create the database with pg out of backend/node_modules,
connecting with the credentials already in config.yml.
Then CONFIG_FILE=config.test.yml node --no-experimental-webstorage backend from the repo root.
CONFIG_FILE is resolved against WIKI.ROOTPATH (core/config.ts), so it is a path relative to the
root and not to backend/. It seeds itself and takes ~25s to reach listening.
Puppeteer is not installed in any workspace, and must not be added to one for a screenshot. Install
puppeteer-core into a scratch directory instead and drive the browser already on the box:
executablePath: '/usr/bin/chromium', args: ['--no-sandbox']. It pulls ~25 packages and downloads no
browser of its own.
Scripting the API rather than the browser, which is the quicker way to get a page and a history in place. Three things about it are not guessable:
- The site ID comes from
GET /_api/bootstrap, which ispublicAccess: trueand answers with the site, the flags and the session — it is what the SPA itself calls on boot. Not fromGET /_api/sites: that needsread:sitesoraccess:admin, so logged out it answers 401, and the site ID is what logging in requires. - Login is
PUT /_api/sites/:siteId/auth/login(not POST) with{strategyId, username, password}. The strategy is the built-in local one, whose ID is fixed assystemIds.localAuthIdinbase.yml. A fresh instance answersnextAction: changePasswordwith acontinuationTokenfor the seededadmin@example.com/12345678; feed that toPUT .../auth/changePassword, which needsstrategyIdas well ascontinuationTokenandnewPassword. The session cookie is good after that. - The auth endpoints are rate limited, and successes are counted too.
limitAuthAttempts(helpers/rateLimit.ts) guards login, 2FA, this password change, passkeys and page unlock with one counter per client address — ten attempts per five minutes, then a fifteen minute ban. A re-runnable script that tries the seeded password before the one it changed it to therefore burns a guaranteed failure per run and eventually locks itself out. Try the changed password FIRST, and clear a ban withDELETE FROM wiki."rateLimits"rather than waiting it out.
Two things block a fresh install's first screenshot. The seeded admin is forced through a
change-password form on first login — fill both input[autocomplete="new-password"], the current
password field being v-if'd away whenever a continuation token is in hand. And the site root raises
the Welcome overlay over the header while there is no home page, so navigate to any other path to
get at the real one.
A successful login is authenticated: true, and it answers nextAction: 'redirect' — so a script
that reads "did this work" as the ABSENCE of a nextAction decides every good login was a bad one,
and then burns its retry on the seeded password against the limiter above.
Saving from the UI goes through a dialog, twice over. A page being created opens the Save As
tree browser, and a save of any kind opens the reason-for-change dialog unless the site's
features.reasonForChange is off. So a script that clicks Save and waits for the request sees
nothing at all, and reads as a broken save; dismiss both. The button is disabled off
editorStore.hasPendingChanges, which is the honest thing to assert on rather than a request.
Tearing down is killing your own PID — a dev instance shows up as node backend too, so match on
start time or the CONFIG_FILE in /proc/<pid>/environ rather than on the name — then
DROP DATABASE wikitest and deleting config.test.yml and data-test/. Neither is gitignored:
.gitignore names /config.yml and /data as exact paths, so a copy under any other name is
tracked and will turn up in the next commit.
TypeScript (backend)
The backend is entirely TypeScript 7 (the native Go compiler — tsc is a platform binary, not a
JS bundle).
There is no build step. Node 26 runs .ts files directly by stripping types at load time, so
node backend and nodemon keep working unchanged as files are converted. tsc is used purely as a
type checker (noEmit) — never to produce output. Do not add a build/dist step.
Consequences of type stripping, all enforced by backend/tsconfig.json:
- Relative imports must carry the real extension. A
.tsfile importing a converted module writes./core/config.ts, not./core/config.jsand not extensionless — Node resolves the literal path. This means converting a file requires updating the specifier in every file that imports it. (allowImportingTsExtensions) - Only erasable syntax is allowed — no
enum, nonamespace, no constructor parameter properties, noexperimentalDecorators. Use union types oras constobjects instead of enums. (erasableSyntaxOnly) - Type-only imports must say
import type, otherwise the import survives erasure and Node tries to load a value that doesn't exist. (verbatimModuleSyntax)
allowJs is off — the backend is fully TypeScript, so a stray .js file would silently escape
type checking rather than be quietly tolerated. locales/metadata.js is the sole exception and is
resolved through its sibling metadata.d.ts.
backend/types/global.d.ts declares the ambient WIKI global as the WikiGlobal interface, wired
to the real module types (WIKI.db is the Drizzle instance, WIKI.models is models/index.ts, and
so on). Only config and data stay any — both are assembled at runtime from YAML plus a JSONB
settings table, so they have no static shape. index.ts and worker.ts build their own local WIKI
literal and assert it to WikiGlobal, since each populates the object progressively.
backend/types/fastify.d.ts augments Fastify: session fields (authenticated, user,
permissions) and the per-route config.permissions used by the preHandler permission hook.
Four dynamic paths are extension-sensitive and invisible to the type checker — they must be updated by hand if the files they point at are ever renamed:
core/scheduler.ts→path.join(WIKI.SERVERPATH, 'worker.ts')(the poolifier pool entry)worker.ts→import('./tasks/workers/${kebabCase(job.task)}.ts')models/authentication.ts→import('../modules/authentication/${stg.module}/authentication.ts')models/storage.ts→import('../modules/storage/${key}/storage.ts'), plus thestorage.tspresence check inhasImplementation()that gates it
scheduler.ts reads tasks/simple/ filenames with /\.[jt]s$/, so task files are extension-agnostic.
worker.ts builds its own minimal WIKI (config + logger + lazy ensureDb()), but the shared
declaration types it as the full object — so worker-only code can reference members that do not
actually exist in a worker thread. Be deliberate about what you touch there.
Conventions established during the conversion, worth following in new code:
catch (err: any)at each site rather than globally disablinguseUnknownInCatchVariables. Strict mode types a caught error asunknown, and this codebase readserr.messageeverywhere; annotating per-site keeps the looseness visible instead of hiding it in tsconfig.- Per-route Fastify generics for request shapes:
app.get<{ Params: { siteId: string } }>(...). The JSON Schema stays as-is for validation and OpenAPI; the generic is what typesreq.params,req.bodyandreq.query. - Pre-existing bugs are preserved, not fixed. Where the type checker exposed already-broken code,
it was left behaving identically behind a narrow cast plus a
FIXME:comment explaining the real fix. A migration should not silently change runtime behavior. SearchFIXME:underbackend/for the list — they are genuine open bugs, not type-checker noise.
Conventions
Style, linting, formatting
oxlint for linting, oxfmt for formatting — not ESLint or Prettier (ESLint is explicitly
disabled in .vscode/settings.json). Both are devDependencies of backend/ and frontend/.
npx oxlint # from backend/ or frontend/ — uses that dir's .oxlintrc.json
npx oxfmt <paths> # config is the repo-root .oxfmtrc.json
Format settings (root .oxfmtrc.json): no semicolons, single quotes, no trailing commas,
bracketSameLine, LF, final newline. 2-space indent, per .editorconfig.
Otherwise follow standard JS rules. Note that much of frontend/ predates oxfmt and still uses
the standard-style space before parens (function initializeRouter ()); new and touched code should
be oxfmt-formatted, but don't reformat untouched files as drive-by changes.
Each workspace has its own .oxlintrc.json — the backend declares the WIKI global and node env;
the frontend adds the vue plugin and the API_CLIENT / EVENT_BUS / Temporal globals. Only the
correctness category is an error.
Both tools handle .ts with no extra configuration, and the backend's oxlint config already enables
the typescript plugin. oxlint does not type-check — run npm run typecheck for that.
Never put two statements in a Vue template attribute. @click="doOne(); doTwo()" builds today
and is a build error the moment the file is formatted, because semi: false and Vue disagree about
the same character. Vue's transformOn decides whether an inline handler is a statement block or an
expression from exp.content.includes(';') — with the semicolon it emits $event => { … },
without it $event => ( … ). oxfmt breaks the handler across lines and drops the semicolon, so Vue
parenthesises two statements and the template fails to compile (Error parsing JavaScript expression: Unexpected token). Write a named handler instead — @click="closeAndRefresh" — as
EditorMarkdown.vue and PageRelationDialog.vue do.
Neither side of that is worth reconfiguring, so don't try: the includes(';') check has no compiler
option behind it, and the parse error is raised by the built-in transformExpression, which
baseCompile runs before any nodeTransforms you could add — and Volar runs the same compiler,
so a build-time workaround would still leave the editor showing errors. On the formatter side,
embeddedLanguageFormatting: "off" does leave attribute expressions alone but also stops formatting
every <script> and <style> block in every SFC. This is not an oxfmt quirk either: Prettier with
--no-semi produces identical output. For a one-off where the inline form genuinely reads better,
<!-- prettier-ignore --> on the preceding line works (oxfmt honors Prettier's marker; there is no
oxfmt-ignore).
Utilities and dates
These apply to every workspace, frontend/ included — not just the backend.
- Use
es-toolkit, notlodash-es. Installed in bothbackend/andfrontend/. - Use the native
TemporalAPI, not luxon. See Backend patterns for the Temporal gotchas worth knowing; they apply on the frontend too. - luxon and lodash-es are being removed entirely. The migration is gradual: when you touch a file that imports either one, convert that file's usages as part of the same change — but don't sweep through untouched files as a drive-by. Once the last usage is gone, both dependencies get dropped.
- Prefer real es-toolkit subpath exports (
es-toolkit/object,es-toolkit/array,es-toolkit/predicate) overes-toolkit/compat. Two lodash helpers are compat-only and have direct equivalents:defaultsDeep(source, defaults)→toMerged(defaults, source)(note the argument order flips) andtoSafeInteger(x)→Number.parseInt(x, 10). - On the frontend
Temporalis a global, declared in.oxlintrc.json.src/boot/temporal.jsdynamically importstemporal-polyfillfor browsers without native support (Safari, as of mid-2026) and is awaited first inmain.js. The polyfill is a lazy chunk (~21 kB gzipped) that browsers with nativeTemporalnever download.
Permissions
There are two kinds of permission, granted separately and checked in different places. Which kind a name belongs to decides how it may be enforced, so it is the first thing to establish about any permission you touch.
Global permissions are held site-wide, bound to no path: access:admin, read:users,
write:users, manage:users, read:groups, write:groups, manage:groups, read:audit,
read:metrics, manage:theme, manage:storage, manage:sites,
read:webhooks, manage:webhooks, manage:system. That is the
list as it stands — the one offered by the group editor
(GroupEditOverlay.vue). They live on a group's permissions column, are flattened onto
req.session.permissions at login (models/users.ts → updateSession), and are what the per-route
config.permissions hook checks. manage:system bypasses every check everywhere.
Five of them are ELEVATED ADMIN PERMISSIONS: write:users, manage:users, write:groups,
manage:groups and manage:system. ELEVATED_PERMISSIONS in models/groups.ts is the list that
decides, isElevated() is the test, and groups.elevatedGroupIds() answers which groups carry one.
What makes them a category is that each is a route to every OTHER permission on the wiki: whoever
can rewrite who holds what can grant themselves anything, in one step or two. So membership of a
group carrying one is itself a privilege, and the guards are written against the whole list rather
than against manage:system alone — stopping at the root permission would leave manage:users
handing out manage:groups, and manage:groups handing back manage:users, with neither step
looking like an escalation on its own.
Three rules follow, and they are enforced in api/users.ts and api/groups.ts rather than in the
models, since they are questions about the CALLER:
- Nobody but
manage:systemmoves a user in or out of an elevated group — on create as well as on edit, and in both directions. Creating an account already inside one is the same act as promoting an existing one. manage:usersmay not touch an account that belongs to amanage:systemgroup at all (systemUserGuard). That guard ismanage:systemonly, not the whole list: amanage:groupsaccount is protected from being re-grouped, not from being renamed.- The two group-editing rungs stop at different places (
elevatedGroupGuard):manage:groupsis stopped only bymanage:system,write:groupsby any of the five.
The list is exposed to clients as a single isElevated boolean on GroupCore, never as the
permissions themselves — a caller who may not read a group still has to know which ones its controls
must not offer.
A site's settings are split across three permissions that do not overlap, so that the look of a site, where its content is kept, and everything else about it are three separate grants:
| Permission | Admin screens |
|---|---|
manage:sites |
General, Analytics, Approvals, Comments, Content Blocks, Editors, Locale, Login — plus creating, deleting and listing sites |
manage:theme |
Theme, and nothing else. PUT /sites/:siteId/theme takes this alone |
manage:storage |
Storage, and nothing else. Every route in api/storage.ts takes this alone |
An administrator who is to change all of a site's settings therefore holds all three.
Webhooks are their own pair, read:webhooks and manage:webhooks (api/hooks.ts), rather than
part of manage:system as they were. A webhook's authHeader is sent verbatim as the
Authorization header of every delivery, so it is a credential for somebody else's service: it
reads back as SENSITIVE_MASK for a caller who may not change webhooks, and the mask posted back
means "unchanged", the same contract module props use. Whoever may edit still sees the value — the
field is theirs to correct. Don't "helpfully"
accept manage:sites on a theme or storage route — the disjointness is the point, and the general
site update (PUT /sites/:siteId) refuses a theme key for the same reason.
The two write:* rungs sit between reading and managing:
| Permission | May | May not |
|---|---|---|
write:users |
create an account | change any existing one; see the list (that is read:users); create into an elevated group |
write:groups |
create a group, rename it, write its page rules, staff an ordinary one | change what a group is ALLOWED to do (the Permissions tab); delete a group; staff an elevated one |
Adding a global permission is the maintainer's call, not yours. The list is not frozen, but a new name reshapes who can do what across the whole instance and every existing group silently lacks it — so propose it and wait for a yes before writing any code that names it. Until then, express what a route needs with the permissions that already exist. This is about adding to the list; using one that is already on it needs no permission from anybody.
Page rule permissions are bound to paths, and to locales and sites: read:pages, write:pages,
review:pages, manage:pages, delete:pages, write:styles, write:scripts, read:source,
read:history, read:assets, write:assets, manage:assets, read:comments, write:comments,
manage:comments, manage:navigation (PAGE_PERMISSIONS in api/pages.ts). A group grants them through rules:
each rule names some of them (roles) plus how it addresses pages (match + path, or tags) and
what it does with them (mode: ALLOW / DENY / FORCEALLOW). Nothing is granted by default, and when
several rules match, the most specific one wins — helpers/pageRules.ts documents the ordering.
Ask WIKI.models.groups.checkAccess(actor, permission, page), or mayOnPage(req, permission, page)
in api/pages.ts.
Consequences worth knowing:
- A page permission cannot be enforced by
config.permissions. That hook reads the group-wide list only, sopermissions: ['write:pages']refuses everybody. A route that turns on a page permission declares no route permission and checks in the handler instead — say so with aNo route-level permissions:comment, asapi/pages.ts,api/assets.tsandapi/blocks.tsdo. - The two names are not interchangeable.
manage:pagesdoes not implywrite:pages: a rule grants the exact strings in itsroles. - On the frontend,
userStore.permissionsis the global list (fromusers/whoami) anduserStore.pagePermissionsis what the session holds AT THE CURRENT PATH (frompages/userPermissions, refreshed per route inApp.vue).userStore.can()ORs the two and treatsmanage:systemas a wildcard, so it answers "may do this somewhere". Gate a control over the page in front of the reader onpagePermissions— that is what the endpoint behind the button will check. manage:navigationasks about TWO paths. It is the one page permission where holding it at the page in front of you is not the whole answer. At the page it buys the navigation MODE — whether this page inherits, overrides or hides its sidebar — which affects nothing above it. Editing the menu's ITEMS additionally needs it on the entry the menu BELONGS to, since those items are shown to every page under that entry: a page that inherits is editing its ancestor's menu, and a page with no overriding ancestor is editing the site-wide one, whichnavigation.menuOwnerRefreports as the home page's path. So a rule over/guideslets that section re-point its own pages without letting it rewrite the menu handed down to it.api/navigation.tshas the pair of checks (mayManageNavAt,mayEditNavItems), and theinheritedroute answerscanEditItemsso the editor knows which of its two halves to offer.- An anonymous request is the guests group, not an absence of groups: that is how a wiki opens
reading, and suggesting edits, to the public. Deny guests explicitly where an account is genuinely
required (
reviewerForinapi/approvals.tsis the worked example). - Never invent a permission name. Nothing validates one, so a name that is not on the lists above
simply never matches:
can('browse:fileman')and friends silently hid the controls they guarded. Adding a genuinely new global permission is allowed but is the maintainer's decision — ask first, as above; never introduce one on your own.
Backend patterns
- The
WIKIglobal. Set up inindex.ts, typed intypes/global.d.ts, available everywhere without importing:WIKI.db(Drizzle),WIKI.models.*,WIKI.config,WIKI.logger,WIKI.cache,WIKI.scheduler,WIKI.events.{inbound,outbound}(Emittery),WIKI.sites/WIKI.sitesMappings(cached site configs),WIKI.ROOTPATH,WIKI.SERVERPATH,WIKI.INSTANCE_ID. - Routes are Fastify plugins:
async function routes(app) { ... }with a default export. - Permissions are declared per-route in
config.permissions, and enforced by a singlepreHandlerhook inindex.ts. The array is OR-ed; a nested array is AND-ed (permissions: ['read:sites', ['manage:users', 'manage:groups']]).manage:systembypasses every check.@fastify/swagger'stransformfolds these into the OpenAPI description automatically — so declaring them is also how they get documented. Only global permissions belong here; see Permissions for the other kind and how they are checked. - Every route needs a
schemawithsummary,tags, and response schemas.hideUntaggedis on, so an untagged route is invisible in the API docs. Reuse$refschemas fromapi/schemas/. - Errors via
@fastify/sensiblehelpers (reply.notFound(),reply.badRequest(),reply.unauthorized(),reply.forbidden()). ThesetErrorHandlerinindex.tsshapes/_api/failures into{ ok, error, statusCode, message }JSON. - Schema changes: edit
db/schema.ts, thennpm run db-generateand commit the generated migration. Never hand-edit an existing migration. Always pass a name that says what the migration does —npm run db-generate -- --name=comment-handles, kebab-case, one or two terms. The script carries--name=scarlett(the branch name) as its default and a later--nameon the command line overrides it, so leaving it off files the change as another…_scarlettdirectory that says nothing about it. - A module prop marked
sensitiveis write-only. A route answering with a module's stored config runs it throughmaskSensitiveProps(helpers/common.ts) first, which replaces every non-empty sensitive value withSENSITIVE_MASK; the client posts the whole configuration back, andisSensitiveMaskis what makes the mask mean "unchanged" rather than a new value. Masking belongs at the API boundary and nowhere earlier — the config the models hand out is what the modules read their credentials from. An empty value is never masked, so dots always mean something is stored, and clearing the field is how a stored secret is removed — except on a create, where there is nothing to keep and the mask leaves the prop unset.manage:systemon the route is not a reason to skip this: the secret still ends up in a browser, a cache and a screen share. Both module-prop surfaces do it — storage targets (api/storage.ts) and authentication strategies (withoutSecretsinapi/authentication.ts) — so a new one is expected to as well. - Dates use the native
TemporalAPI, not luxon (no longer a backend dependency).Temporalis a global in Node 26 and is typed by the TS 7 lib, so it needs no import. Four things to know:Temporal.Instantaccepts exact time units only —add({ days: 1 })throws. Since these are all UTC instants, use{ hours: 24 }.- Temporal types have no
valueOf, soa < bthrows. Compare withTemporal.Instant.compare(a, b). Instant.toString()defaults to nanosecond precision; pass{ smallestUnit: 'millisecond' }for values written to postgres or compared as strings, which is what the rest of the codebase emits.- Converting:
date.toTemporalInstant()from aDate(what drizzle returns fortimestampcolumns),Temporal.Instant.from(str)for postgres-format strings (what rawdb.execute()returns), andnew Date(instant.epochMilliseconds)going back the other way.
Frontend patterns
- Templates are plain HTML. A handful of pre-3.x leftovers are still
<template lang="pug">— check the file you're editing rather than assuming. - UI components come from
components/shared/, registered globally, so<w-btn>/<w-input>/<w-icon>need no import. Each one is scoped to how this app actually uses it rather than to the full API of the framework component it replaced; the header comment in each file says where they differ. Add a prop there rather than reaching around it. - HTTP calls go through the
kyclient, reachable as theAPI_CLIENTglobal (declared in the oxlint config, so no import needed) — e.g.await API_CLIENT.get('sites').json(). It handles the/_apiprefix; authentication is the session cookie, sent with every request. - Cross-component messaging uses the
EVENT_BUSglobal (mitt). - State lives in Pinia option stores. For utilities and dates use
es-toolkitandTemporal— see Utilities and dates; thelodash-esandluxonstill present in older files are on their way out.
Drawings (the Excalidraw editor)
A page whose whole body is one drawing, written with excalidraw and stored as the .excalidraw
document Excalidraw itself writes. frontend/src/editor/excalidraw/ is the whole of it, plus
EditorExcalidraw.vue for the Vue side.
This is the only React in the app, and it is quarantined. Excalidraw is a React application and
there is no Vue port worth having, so editor/excalidraw/index.js owns react, react-dom and
@excalidraw/excalidraw, and nothing else imports any of them. EditorExcalidraw.vue hands it a
<div> and gets back a plain object of methods. There is no JSX and no JSX toolchain —
createElement is called directly, which one component can afford. Both are reached only through the
defineAsyncComponent in pages/Index.vue, so ~405 kB gzipped is fetched the first time somebody
opens a drawing and never on an instance that has none.
A reader never loads any of it. The page's render is an SVG the editor exported at save time,
which is the same bargain the markdown pipeline already makes — a page's HTML is produced once, by
the browser that changed it. Three things follow, and each is load-bearing:
- The drawing is exported LIGHT, always, and
_page-contents.scssinverts it under.body--darkwith Excalidraw's ownTHEME_FILTER. Excalidraw does dark mode by inverting the finished picture rather than by recolouring what is in it, so an export made at night has the filter baked onto its root element — stored that way it would be a photographic negative for every reader on a light page. One stored render, two themes to serve it in;exportSceneSvgand that rule have to stay in step. skipInliningFontsis on, so the SVG names its fonts instead of carrying a base64 copy of each one. The@font-facerules a reader needs are generated at build time from the Excalidraw package byexcalidrawAssetsinvite.config.jsand imported bymain.js— about a kilobyte, fetching no font until a glyph calls for one. It also keeps the font subsetter out of the bundle, which is a megabyte of WebAssembly-in-JavaScript for a job nothing here needs done.- The fonts are served from
/_assets/excalidraw/, declared aswindow.EXCALIDRAW_ASSET_PATHbefore Excalidraw loads (assetPath.js, a side-effect module imported first for exactly that). Excalidraw appends its own CDN as a fallback even when that is set, so shipping the complete set is the only thing that keeps a reader's browser at home — which is whatofflineis about. Xiaolai is deliberately not shipped: 13 MB for Excalidraw's CJK handwriting fallback, so a drawing with CJK text in it needs the internet. One name inEXCALIDRAW_SKIPPED_FONTSreverses that.
An image inside a drawing is an asset, not base64. Excalidraw keeps pictures in a files map as
data URLs; left alone that puts a screenshot in the content column and in every version of the page
for ever. Nothing about that field requires a data: — it is assigned to img.src and written as an
SVG href — so a pasted image goes through editorStore.addPendingAsset like a markdown one, and
the upload just before a save rewrites it. Note addFiles deliberately ignores an id it already
holds, so repointing an image means giving it a NEW file id; the abandoned entry is dropped by
serializeScene, which keeps only the files an element uses.
Collaboration is the existing room with a third shared type — read core/collab.ts and
editor/visual/collab.js first. Elements live in a Y.Map keyed by id rather than a Y.Array,
because z order is carried by each element's own fractional index and there is no array position
for two authors to fight over. The seed is built client-side under SEED_CLIENT_ID = 2; the server's
is 0 and ProseMirror's is 1, and a seed reusing an id the document has seen is discarded in silence.
buildSeed on the server skips content for a canvas editor (CANVAS_EDITORS), since a scene is
tens of kilobytes of JSON that no client would ever bind to.
The sanitizer had to learn two things (models/rendering.ts): the inert text and font attributes
an export puts on every text element, and the image element. Neither grants anything new — href
is scheme-checked like every other link, and img beside it could always fetch a picture. data: is
not an allowed scheme, which is the other half of why images go to the asset store. extractText
also puts a space after each svg text, because an export writes every LINE as a text of its own
and a two-line label came out as one unsearchable run of letters.
Ctrl+S does not save here, the same as in the Visual and Redirection editors: that shortcut is a Monaco keybinding, not something each editor implements.
Storage targets
A storage target is one module from modules/storage/<key>/ configured for one site. Six ship, each
with a real storage.ts:
| Key | What it is |
|---|---|
db |
Enabled on every site and impossible to turn off — bytes in the asset's own row |
disk |
The wiki's tree as files at <root>/<locale>/<folders…>/<file> |
git |
That same tree in a repository, committed per change and synced with a remote |
s3 |
Amazon S3 and anything speaking its API — R2, Spaces, B2, Wasabi, MinIO |
azure |
Azure Blob Storage |
gcs |
Google Cloud Storage |
sftp |
That tree again, on a remote host over SSH — a copy, never a delivery source |
A new module is a directory with a definition.yml and a storage.ts exporting the StorageModule
contract, and hasImplementation gates both dispatch and the admin area's action buttons on the
latter existing.
disk, git and sftp are the same tree in three places, and share helpers/storageFiles.ts
for all of it: the layout, the front matter, what makes a file a page, and importTree's adoption
walk. sftp hands importTree its own readFile — the only thing that differs about a tree on
another machine — and uses path.posix throughout, since a wiki on Windows still talks to sshd in
slashes. Its connection is cached per target and serialized, because a single ssh2-sftp-client does
not support concurrent operations, and dropped on a connection-shaped failure so the next operation
reconnects. Unlike 2.x's SFTP module it reads as well as writes, so a site can serve from it and
import a tree that was put there from outside.
The three object stores are one file of client calls each. put, get, remove and copy — the
ObjectStoreClient in helpers/storageObjects.ts — and objectStorageModule builds the whole
StorageModule from them. An object key is a path, the same one disk would write, so a bucket and
a folder hold a site's content laid out identically and pathPrefixFor decides the shape of both.
Each of the three also takes a pathPrefix, which is the segments that key starts with — empty by
default, so the tree sits at the root of the bucket, and set when the bucket has to be shared with
something else, since an object store has no folders to keep two tenants apart. It is per target,
unlike everything pathPrefixFor answers, for the same reason the bucket name is: which store the
tree goes in and how the tree is laid out are different questions. It is normalized rather than
validated — surrounding and doubled slashes go, and so do . and .. segments, which name a literal
object in a bucket rather than a relative path. A custom baseUrl still stands in for the bucket and
not for the prefix, because the key is signed prefix and all. Object stores have no rename, so
moveObject copies and then deletes, in that order, and never deletes on a copy that failed. Credentials are optional on all three: left empty, each SDK falls back
to the machine's own identity (an IAM role, a managed identity, a workload identity), which is how a
deployment keeps a long-lived secret out of the database. Only exportAll is offered — there is no
importAll, because nothing but the wiki writes into these buckets, which is exactly what makes git
different.
Nothing else may live under modules/storage/. refreshFromDisk reads every directory there and
expects a definition.yml in it; one without takes every storage module down with it, since the
read is wrapped in a single try/catch that empties definitions. This is why the tree logic shared by
disk and git — the front matter, the file name a page is filed under, what makes a file a page
rather than an attachment, and the walk an import does — sits in helpers/storageFiles.ts instead.
Content is written to every target that claims it, and read from one. Those are two separate questions with two separate answers, and conflating them is the way to get this wrong:
-
Written — a target's
contentTypes.activeTypessays what is stored there, and a site may store the same kind in several places at once. An upload goes to all of them; the admin area's Targets tab is where that is set, per target. -
Read —
assetDelivery.servedTypesnames the content types a reader's request is answered from that target, at most one target per type across the site. The Content Delivery tab sets it, and a target may only be nominated for a type it also stores (validateTargetrefuses the pair). Pages are never nominated: a page is read from its own row, always.Direct access. An object store can answer instead of being read through:
assetDelivery.modeisstreaming(the default — bytes through the wiki) ordirect, where both serving routes (controllers/files.tsand the asset content API) answer 302 to a signed URL and the bytes never touch the server.storage.directAccessUrlForis the whole decision and both routes call it. Three things it insists on: the target must be the nominated source for that content type (deliveryTargetsFor's head standing in as the database is not a nomination), the module must implementpresignAsset, and the link's lifetime is capped at 7 days because no provider signs for longer. The redirect is cached for half the link's life, so a cached redirect can never outlive the URL in it.A signed link carries none of the wiki's page rules — it works for whoever holds it until it expires. That is what makes the store able to serve without asking the wiki, and it is why the expiry defaults to
5m. When signing fails, the site'sstorage.directAccessFallbackdecides:stream(default) serves the bytes the slow way so a bad credential costs performance rather than every image on every page,errorfails the request so it cannot go unnoticed. The target records awarningeither way.A custom
baseUrlis signed for, never swapped in afterwards. S3 and GCS sign the host, so rewriting it invalidates the signature: S3 builds a second client (bucketEndpointwith the URL as theBucketwhen the domain is the bucket,forcePathStylewhen the bucket is a path segment), GCS passescname, and Azure alone can simply swap the origin because a SAS signs the canonicalized resource and not the host. CloudFront is therefore out of scope — it needs its own key pair and signing scheme.A module can decline to serve at all.
assetDelivery.isDeliverySupported(default true; false only forsftp) keeps a target out of the Content Delivery tab, out ofsourceOptions, and gets a nomination refused byvalidateTargetand cleared byupdateTarget. It is still written to, exported to and imported from — the point is that every image on every page should not be an SSH round trip. It does stay in the read fallback list, sorted behind even the database:offloadUncheckedcan leave a file whose only copy is there, and a slow answer beats telling a reader it is gone.Only an explicit nomination moves delivery. A type nobody has been nominated for is served from the database (
deliveryTargetsFor), never from whichever other target happens to be enabled — enabling one says where content is written, and a target enabled after an upload holds none of the existing files anyway. Disabling a target therefore puts delivery back, andupdateTargetclearsservedTypesas it goes so that re-enabling it later does not silently take the content type with it. The database gives the role up only by not holding the type at all.
So neither an asset nor a page records where its bytes went — there is no single place — and each
target derives where its own copy sits from the tree, the same way for both. resolveTargetFor and
assets.storageInfo are gone; writeTargetsFor and deliveryTargetsFor replace them.
Where a file sits under a target's root is one answer per site too, and storage.pathPrefixFor
is the only thing that gives it: the leading segments of every path any path-based module writes,
with parseStoredPath as its exact inverse for reading a folder back in. Two site settings shape it,
both on the Configuration tab and both read through storage.pathLayoutFor:
storage.sitePrefix(off) files the tree under a folder named after the site. Off because the configured root already is that site's folder; on is what lets two sites share a location, each ignoring the other's half of it.storage.localePrefix(on) brackets the tree by locale, which is what keepsguides/logo.pngfrom being the same file in every locale. Off, the site stores its primary locale and no other — there is nowhere to put the rest — sopathPrefixForanswers null for them and the target is skipped: a page copy silently (the page is in the database either way), an asset copy after acanStorecheck, so that the upload still succeeds as long as some other target takes the bytes.
Neither setting moves anything already stored; the disk target's export and import actions are how content crosses from one layout to the next.
Which content type a file is, is one answer per site, not per target. large is a category of
its own rather than a modifier — that is what lets a target take the 40 MB video without also taking
every thumbnail — and the size at which it starts lives in the site's config as
storage.largeThreshold (storage.largeThresholdFor, the admin area's Configuration tab). It
has to be shared: a file the disk target called large and the database called an image would be
claimed by neither target, or by both. The whole storage configuration of a site — the site-wide
settings and every target — is read and written as one, through GET/PUT /sites/:siteId/storage.
Everything else follows from that:
- A write must succeed everywhere; a read may fall back.
storage.putAssetfans out and throws if any target refuses, failing the upload — an asset may have no database copy, so a half-stored one must not be reported as saved. Refusing is not the same as having nowhere to put it: a target whosecanStoresays no is never asked, because nothing has gone wrong and the file may well be storable elsewhere. Only when nothing can hold it is the upload failed, and then with aCustomError— a plainErrorreaches the client as a bare 500, since the error handler inindex.tsonly forwards a message that came with astatusCode.getAssettries the nominated source and then every other target holding the content, database last, because a target enabled after an upload never received it. Pages are gentler still:mirrorPage/removePage/relocatePagelog a target that could not keep up and carry on, since the database always has the page. - The disk target's
exportAllis a copy, not a move. It writes out everything it is configured to hold, overwriting, and touches neither the database nor any record — which is how content that predates the target being enabled gets onto it. - The move is the database target's
offloadUnchecked. Turning a target on only affects what is uploaded from then on, so a site that unticks a content type on the database is still carrying every file of that kind ever uploaded — and carrying it unreachably, since a target is only read for a type it stores. That action reads each of those out of its row, writes it to every enabled target holding the type, reads it back to check and only then clears thedatacolumn. An asset with no destination keeps its copy and is reported as stranded: nothing is cleared that is not known to be somewhere else, because this is the only copy of the bytes. Metadata is untouched throughout —datais the one column it empties. - Renames have files to move on every target.
assets.relocateAssetstakes the old location from the caller and reads the new one off the tree;tree.renameFolderdoes the same for everything beneath a renamed folder, pages included (moved, not rewritten — nothing changed). - Thumbnails always stay in the database (
assets.preview) — the file manager asks for a screenful at a time, and a slow target must not cost a wiki its file browser. pages.adoptStoredPageandassets.adoptStoredFileare the way back in, for the disk target's two import actions. Both take anoverwriteflag, and it is the only thing separating them:importAllleaves a path the wiki already has alone, because reconciling a file changed on both sides is a merge and belongs to a target with history, whileimportAllOverwritelets the folder win — for a restore, where there is nothing to reconcile. A page is replaced throughupdatePage, so its previous version is in its history; an asset has none, andreplacealso dispatches the new bytes to every write target, since the copy a reader is served is usually the database's. Imported content is rendered with no script or style permission whoever ran the import, since the file need not have been written by them.- On import a file is a page if its extension is reserved, or if it declares an
editorin its front matter. A text page is front matter plus the source; a JSON page — a redirection today — is one JSON document with the metadata at its top level and the source undercontent.
Pages and assets share one folder, and the site's pageExtensions is what keeps them apart. A
page is stored under its editor's extension (md, html, adoc, json), while the tree holds its
name without one — so page notes/readme and an attachment called readme.md are different names to
the wiki and the same file on disk. Three rules stop them ever meeting, and all three live in the
models rather than in the storage module:
pageExtensionsare reserved.assets.uploadrefuses an attachment using one — a.mdfile is a page, so uploading it as an attachment is a mistake rather than a collision. This is the whole of it on a default site (md,html,txt).- Both sides check anyway. For an extension a site has taken off that list,
assets.guardAgainstPageCollisionandpages.guardAgainstAssetCollisionrefuse whichever arrives second. Extensions must match to collide:readme.pdfsits happily beside the pagereadme. - Nothing guesses at a name.
StoragePageRefcarriescontentType, so a delete or a move touches exactly one file — a target that tried each extension in turn would delete the attachment next door.
A target's state column is how it is behaving, not how it is configured. { status: 'healthy' | 'warning' | 'error', message, updatedAt }, written only by storage.recordState as the model
dispatches to a module, absent from StorageTargetInput, and reported by the Status card. error is
a failure that was raised to whoever asked (a refused upload); warning is one that was swallowed
because the request succeeded anyway (a page copy that could not be written). The last operation
wins — a later success clears an earlier failure, which is what makes a full disk that gets emptied
stop reporting itself without anybody dismissing anything. Nothing probes a target proactively, so a
misconfigured one reads healthy until something is actually asked of it.
The git target is the disk target plus history and a remote. Same tree, same helpers, and then:
- Commits are local and immediate; the network is batched. A page save writes, commits and
returns —
prepareRepodeliberately never contacts a remote, so an unreachable one cannot make the wiki slow to edit or fail an upload.ensureRemoteis the half that does, and onlysynccalls it. syncruns on a schedule.tasks/simple/sync-storage-targets.ts, on a* * * * *jobSchedulerow seeded byjobs.init(), walks every enabled target whose module declares asynchandler (storage.syncableTargets) and runs it throughexecuteAction, so a failure lands on the target's Status card. The Force Sync action is the same call on demand. In an HA set the scheduler hands the job to one instance, which is the one whose working copy syncs — each instance keeps its own.- How often is
storage.syncInterval, per site (the Configuration tab,syncIntervalFor, default5m). Since it is per site, one cron row cannot express it: the tick is every minute — as fine as the shortest interval anyone can set — and the task skips the sites whose turn it is not. Due-ness isepochMinute % intervalMinutes === 0rather than a stored last-sync time, so nothing has to be persisted, two instances agree without coordinating and a restart changes nothing. The cost is that a missed tick is not caught up, which for something running all day is the right trade. An interval that will not parse means never, not every minute. - A pull is authoritative, and that includes deletions. What comes back is applied to the wiki
with
overwrite, and a commit that deleted a file deletes the page or asset here too. So push access to the remote is effectively write access to the wiki. Which of the two a vanished path was has to come off the tree rather than the file: the stem is looked up first and only counts as the page ifpages.storageFileNameOfmatches the name that went, so a deletedreadme.pdfnever takes the pagereadmewith it. - A conflict is settled, never left open. The pull is
--rebase --autostash, and a rebase that conflicts stops and waits for a human that this working copy does not have — with the index unmerged, which git then refuses every write against, so it is not one failed sync but every commit after it (ensureRemote's checkout is the first thing to fail, and what such a target reports).pullRebasetherefore rolls a conflicted rebase back and pulls again with-X ours, which during a rebase is the remote, consistent with a pull being authoritative. What-Xcannot settle is a file one side changed and the other deleted; that fails the sync, but leaves the working copy usable.abortInterruptedalso runs at the top of every sync and inprepareRepo, so a copy somebody left mid-rebase by hand heals itself instead of needing Purge. - A working copy started again from nothing re-attaches itself. It is a directory in a container
while the content is a database elsewhere, so an upgrade that replaces the container without a volume
for
data/repotakes it with it — andprepareRepothen inits an empty one, the next page save commits into it, and the wiki has the remote's own files under a second root commit.syncaskssharesHistoryWithbefore it pulls, and with no commit in commonreattachmerges the remote's history in with--allow-unrelated-histories -X oursinstead: the remote's history and every file the wiki has not re-saved come back, the working copy's version wins where the two overlap (these files were written from the database since the copy was created, so the remote's copy is the older one), and the push that follows puts the two in step. It runs in every mode — apushmode force push from a working copy created ten minutes ago would otherwise replace the whole repository with it. The wiki itself is not written to andapplyIncomingis skipped for that sync, since the diff is the entire repository; Import Everything is what takes in content the repository has and the wiki does not. - The diff, not the tree. Incoming changes come from
git diff --name-status -M -zbetween the commit the branch was on and the one it is on now — a sync runs every few minutes and cannot read every file each time.-zbecause a path may contain anything, newlines included. - Every operation is serialized per target by
withRepo: git locks its index for the length of a write, so two concurrent uploads would otherwise have one fail onindex.lock. - An empty commit is never made. Most page saves do not change the stored form of the page (a
re-publish, a tag reordered into the same order), and
commitPathschecksdiff --cachedbefore committing so the history says what actually changed. - Operator git config is inherited on purpose — including
commit.gpgsign, which will fail every commit if it is on without a usable key. The failure surfaces as the target's recordederror.
A change carries who made it, and only git cares. StorageAssetRef and StoragePageRef carry an
optional actorId, which is the user id the models already had in hand at each dispatch site;
storage.actorFor turns it into a name and an email, cached indefinitely because a page save is a hot
path and a stale commit author costs nothing. Absent for a change no one person made — a folder rename
that moved a hundred files, a scheduled sync — and the git target's configured default author stands
in. A scheduled pull creates content, which needs an author, so it uses
users.getSystemActorId(): the wiki's longest-standing active administrator.
Git's alwaysUseDefaultAuthor makes that stand-in universal, for a repository whose history must
not carry the wiki's accounts. commitAuthor then returns the default without calling actorFor at
all — not looked up and discarded, so there is nothing to leak by mistake. Note the committer is the
default author in every case regardless: it is the repository's own user.name/user.email, which
prepareRepo writes from those same two settings, and which is why they are in
configFingerprint — renaming the default author has to re-prepare the repository to take effect.
Not to be confused with <dataPath>/cache/files, the serving cache in the assets model. That one is
derived and swept; a storage target is where content actually lives.
Icons
Icons come from Iconify and are referenced the way Iconify references them — <prefix>:<name>,
e.g. mdi:account-edit. That string is all that content, navigation items and page relations ever
store; no SVG is ever written into content.
- Admin (
AdminIcons.vue→/_api/icons) manages which sets exist: adding a set stores its metadata only, and enabling/disabling one controls whether its icons can be searched and filled in. models/icons.tsresolves a reference through four tiers — memory, disk (<dataPath>/cache/icons/<prefix>/<name>.json), theiconsdb table, then the Iconify API. Only the db is permanent; the disk cache is derived and starts empty on a fresh instance, so never treat it as storage. The upstream API is consulted only for an icon nobody has used yet, is capped per minute (public routes can trigger a fill), and is skipped entirely whenofflineis set.- Serving is
controllers/icons.tsunder/_icons, cached for a year and immutable. Rendering a page never resolves an icon server-side. - Frontend: render every icon with
<w-icon :name>(components/shared/WIcon.vue). Components that take aniconprop go through it too, so every form works there.- Every Iconify reference written literally in this repo's source is inlined at build time by
scripts/generate-icons.mjsintosrc/assets/icons.generated.js(committed) and drawn as an inline<svg>. Runnpm run iconsafter adding or removing one;npm run icons:checkfails if the bundle drifts. This is why the interface needs no icon webfont — and why nothing an administrator does to icon sets can blank it, which fetching at runtime could not promise: resolution is gated on the set being enabled, and deleting a set drops every icon stored for it. - A reference built at runtime — an icon a user picked, stored on a page or nav item — is
invisible to that scan and falls through to
iconify-icon, resolving against/_iconsas before. A name assembled by concatenation is therefore a bug: make it a literal. img:…renders as an<img>. Legacylas la-cog/mdi-checkwebfont names are mapped onto their Iconify equivalents for data written before the fonts were dropped; do not write new ones.
- Every Iconify reference written literally in this repo's source is inlined at build time by
- Picking an icon calls
POST /_api/icons/materialize, which is what guarantees the wiki can serve it afterwards without the Iconify API.
The app shell and SEO
The compiled SPA is one document for every path on the wiki, served by the setNotFoundHandler in
index.ts — which is the fallback rather than a route because a page lives at any path a user cares
to give it, and the frontend's router is what resolves one.
That document says nothing about the page at its URL until a browser has run it — a <title> reading
Wiki.js for every page of every wiki — and plenty of clients never run it: a chat client
building an unfurl card, an AI crawler (GPTBot, ClaudeBot, PerplexityBot and friends fetch raw HTML
and render nothing), a search engine that does not render, a reader with JavaScript off.
helpers/appShell.ts is what puts something in the document, and renderAppShell is the whole of
the decision.
There is no server-side rendering here, and none is wanted. A page's HTML is already a string in
pages.render, produced once in the editor's browser at save time — so describing a page is reading a
row and two string insertions, not running Vue, a component tree or a second build. Anything that
proposes rendering on the server is solving a problem this schema does not have.
Every request gets a head describing the page at its URL. What separates the two kinds of document is whether the client will run the app, and each has its own function:
-
fragmentsForCrawler— for a client that will not. The PUBLIC's view of the page (pages.describePageForPublic, which islistForSitemap's question asked of one page): a head, the page's markup appended, and 404 where the public may read nothing there. What goes in is what the GUESTS group may read and nothing else, which is what makes it the same document for whoever asked — and therefore the only half that is cached and handed on. -
fragmentsForBrowser— for a browser that will. The head alone, describing the page as THAT requester may see it (pages.describePageForRequest), which makes exactly the cut the page route makes:read:pagesper path, and any signed-in session sees an unpublished page while an API key is answered as the public is. Never cached, because the answer is one reader's. It carries no body (the app is about to draw the page properly, so a copy is bytes on every hard navigation for content the browser discards), never 404s (whether a path this reader may create is empty is the app's own flow to present) and adds no page-specificnoindex.It matters even though the app sets the title itself a moment later: the document's own title is what the tab reads while the bundle loads, and what a bookmark or a history entry made before boot finishes keeps for ever.
-
The head is columns, not prose:
<title>composed exactly asMainLayout.vuecomposes it so the tab does not jump when the app boots,description, a canonical link,hreflangalternates from the page's locale group, and theog:/twitter:pair — which is what a link pasted into Slack or Discord reads, and the surface this was most visibly missing. -
The body is the stored render with everything that would run taken out —
<script>,<style>, inline handlers (stripActiveMarkup). A page whose author holdswrite:scriptskeeps those in its render and the app runs them when it draws the page; a copy of the same markup in the document as the browser parses it would run them a second time, before the app exists. Not a sanitizer — the render was sanitized at save time — just the same markup with less in it. -
It lands in
#wiki-prerender, which both sides know about.frontend/index.htmlhides it and restyles it inside<noscript>, so a reader with JavaScript off gets the page as prose;main.jsremoves the element before Vue mounts. The<noscript>typography puts back the browser defaults Tailwind's preflight took away and is deliberately not a copy of the content styles, which belong to elements the app draws. -
An anonymous request to a page path with nothing public at it answers 404, where the shell used to answer 200 everywhere — which is what taught a crawler that every URL on the wiki exists. No page, an unpublished one and one the guests group's rules refuse are one answer, since the difference is not something to tell whoever is asking. The site root is the exception and always answers 200: there is something to show there whatever the database says — the welcome screen of a wiki with no home page yet, a login form on a wiki that is not public — and a root answering 404 would report a working instance as broken to every uptime check pointed at it. Note the consequence on a private wiki, where the guests group denies everything by default: every page path answers 404 to an anonymous client, which is the truthful answer and matches the empty sitemap such a site already serves.
-
Indexing is one header and never a tag.
robotsTagForfolds the site's General → SEO settings together with the document's own say — a page marked not searchable, a path with nothing public at it, and every URL that is not a page at all (/_admin,/_search: an interface, not content) are allnoindex.X-Robots-Tagrather than<meta name="robots">because it reaches the same clients and cannot fall out of step with itself. -
The fragments are cached and the shell is not.
WIKI.cacheholds the head and body per origin-and-path for ten minutes — the sitemap's figure, for the sitemap's reason — while the shell is re-read per request so thatnpm run buildinfrontend/takes effect immediately. The key carries the request's own host, because a canonical link does; hence the crudeSHELL_CACHE_MAX_ENTRIESceiling, without which anybody could grow the cache by inventing hostnames.invalidateAppShellCache()is called from every page mutation and from the two placesinvalidateSitemaps()is. Nothing a particular requester holds ever reaches it: the key has no session dimension because the only thing cached has no requester in it. -
groups.actorForPublic()is the public as an actor, and is deliberately separate fromactorForRequest: it is the guests group with no group-wide permissions, asked without a request in hand. Both things that get cached and handed on — the sitemap and the public document — are built from it, and neither may be built from anything one requester happens to hold.
What already existed and is unchanged: controllers/rootFiles.ts serves robots.txt and
sitemap.xml (with hreflang alternates), so discovery was never the missing half — the
document was.
Analytics
A tracking tag from one of a dozen third-party services, turned on per site under Admin →
Analytics. models/analytics.ts, api/analytics.ts and modules/analytics/<key>/.
A module here is two YAML files and nothing else. definition.yml declares what the provider is
and what it needs configured (the same props shape every other module type uses, read through
parseModuleProps), and code.yml holds the markup it contributes. There is no analytics.ts
beside them and there is nothing to load: the whole of what a provider does happens in the reader's
browser, so the wiki's only job is to put the right string in the right place. Unlike
modules/storage/, a directory that cannot be read is skipped with a warning rather than emptying
the list — a provider nobody can turn on is better than every site's existing tags going quiet.
The markup is served, never injected by the app. It goes into the document renderAppShell
hands out, so it is in the HTML of every response — including the one a client that will not run
JavaScript receives. That is the point rather than an implementation detail: several providers verify
an installation by fetching the page and looking for their snippet, which a tag the SPA adds after
boot would fail, and a tag that arrives after boot has already missed the page load it exists to
measure. code.yml has two slots, head and bodyStart; the second exists only because Google Tag
Manager's <noscript> fallback is an <iframe> and so cannot go in the head. The analytics head goes
in ahead of the theme's own head injection — a tag runs as early as it can, and the theme field is an
override.
The administration area is the exception and gets no tag. What happens under /_admin is the
wiki being configured rather than read, and it has no business in a report of what a site's readers
looked at — nor in whatever a session-replay provider would make of somebody typing a credential into
an authentication strategy. analyticsInjections sorts documents by their own URL and nothing else,
so it is a hard navigation to an admin path that comes back clean; walking into the admin area
through the app still carries whatever tag the document it started from loaded, and there is no
taking that back without the per-provider client-side layer described below.
The consequence is that a provider sees the initial document load and no more. This is a single
page app, so moving between wiki pages is a router transition and not a navigation; a provider that
reports views on its own (gtag's page_view, Plausible's automatic pageview) will count one per hard
navigation. There is no per-provider client-side layer dispatching a view per route change, and
adding one means writing a dispatch for each provider's own API.
Configuration lives in the site's config blob, under analytics.providers, keyed by module —
not in a table. Every request that produces a document needs it, WIKI.sites already holds the site
configurations in memory on every instance, and sites.updateSite already reloads them across the
cluster and drops the app shell cache. So a tag costs no query and a saved change applies to the next
request.
Values are escaped by context, declared in the template. A placeholder is {{js:prop}},
{{attr:prop}}, {{num:prop}} or {{bool:prop}}, because the same value goes into different
places — a Matomo server URL is a JavaScript string in the tracker and an attribute in the
<noscript> pixel below it. The js escape also covers <, > and & as \uXXXX: the contents
of a <script> are not parsed for entities, but the HTML parser still ends the element at
</script. This is about correctness, not privilege — manage:sites is the trust boundary here,
the same as for the raw head and body fields under Admin → Theme that this markup lands beside.
An enabled provider with an empty required prop renders nothing at all. requires in the
definition names the props that must be filled; a tag carrying an empty tracking ID is not collecting
less, it is reporting to nothing, so the provider is skipped and the admin area names the empty field
instead. A num placeholder that is not a number drops its whole snippet for the same reason — a bare
var x=; would take every other script on the page with it.
Nothing here can be sensitive. Every value is rendered into a document served to the public, so
a prop that had to be kept out of a browser could not be used by a provider in the first place. This
is why api/analytics.ts is the one module-prop surface with no maskSensitiveProps on the way out.
Comments
Two things wearing one name, and models/comments.ts is the seam between them. Only one provider
is in use per site — two comment widgets on a page are two separate discussions of it, and neither
of them is the discussion. That is what makes this screen different from Analytics, where several
providers may be on at once.
A third-party provider is two YAML files, exactly as an analytics provider is: a
definition.yml (what it is, what it needs configured, the same props shape read through
parseModuleProps) and a code.yml with the markup it contributes. Nine ship — Artalk, Comentario,
Discourse, Disqus, Giscus, Hyvor Talk, Isso, Remark42, Waline. There is no comments.ts beside them
and nothing to load: the discussion lives in somebody else's service. A directory that cannot be read
is skipped with a warning rather than emptying the list, as under modules/analytics/.
The built-in provider is this wiki, and deliberately has no module directory: its comments are
rows in the comments table, served by api/comments.ts and drawn on a Talk tab beside the article.
Its settings are declared as BUILTIN_DEFINITION in the model so that the admin screen renders one
kind of form for every provider rather than two.
Where the markup goes is the one thing that is not like Analytics. An analytics tag is served in
the document; a comment widget belongs at the bottom of the article, and moving between wiki pages
is a router transition and not a document load — a snippet baked into the shell would initialise once
and then show the first page's discussion for ever. So the rendered snippet rides along on the site
payload (comments.publicConfigFor, narrow on purpose — the stored configuration holds an Akismet
key) and PageCommentsEmbed.vue mounts it per page. code.yml has three slots: head (added once
per document and awaited), main (the container), body (the init script, run after both). Scripts
are re-created as real elements — one that arrived through innerHTML never runs — and go INSIDE the
container, which giscus and Isso depend on.
Placeholders are split between the two sides. {{js:prop}} / {{attr:prop}} / {{num:prop}} /
{{bool:prop}} are resolved on the server as they are for analytics; {{js:page.url}} and the rest
of the page.* family are left in the string for helpers/commentsEmbed.js to fill in per page,
escaping by the same rules. A provider that is selected but missing a required prop serves nothing at
all rather than a widget pointed at no account.
The built-in provider's permissions are PAGE rules, not the group-wide list, so none of its routes
declares config.permissions — every one resolves the page and asks mayOnPage. read:comments to
see a discussion, write:comments to post and to edit or delete your own, manage:comments to edit or
delete anybody's. The two are not interchangeable and neither implies the other.
- Guests can take part, where a rule grants them
write:comments— that is how a public wiki opens a discussion. A name and an email are required of them; the email is stored and never served, and is what the spam check is given. A guest cannot edit or delete, because there is no session that identifies them as the author and "their own" has nothing to mean. - Two things stand between a comment and the table. The site's posting cooldown (
30sby default,0for none) is counted per account and per address for a guest, through the same postgres-backed counter the login limit uses, so instances behind a load balancer agree about it;manage:commentson the page is exempt, since answering five threads in a row is what moderating looks like. And an optional Akismet key, which is the onesensitiveprop here: masked at the API boundary like every other module secret. Akismet fails open — a timeout or a revoked key lets the comment through and logs it, because a wiki that silently stops accepting comments is worse than one that lets a spam comment past. A comment it calls spam is refused outright; there is no moderation queue yet, which is what themetacolumn is room for. - Replies are one level deep, enforced in the model: a
parentIdnaming a comment that is itself a reply is rewritten to that reply's own parent, so answering the third message in a thread puts the answer at the bottom of the thread. Deleting a comment takes its replies with it, by the foreign key's own cascade — half a conversation is not worth keeping. - Markdown is rendered in the browser, at display time, by
frontend/src/renderers/comment.js— a second, much smaller renderer than the page pipeline.html: falseis the whole security boundary: markdown-it escapes every<it is given, so nothing stored is ever HTML and no sanitizer's older rules can be served back. No headings, no images, no tables; every link leaves withrel="nofollow ugc noopener". Rendering at display rather than at write is also what lets a mention re-resolve instead of freezing whatever a handle pointed at on the day it was written. - A mention is
@handle, andusers.handleis a column with a unique index onlower(handle)—@anameans one person or it means nothing. It is null until somebody picks one, and a user without one is simply not mentionable; nothing is derived from a display name on anybody's behalf. It is edited under Profile → Info and is NOT gated onallowProfileEditing, because no identity provider owns a wiki mention handle. The comments endpoint resolves the handles of a whole page in one query and the renderer links only those, so a mention never points at whoever took the handle later. - The Talk tab is for the built-in provider alone.
Article/Talkabove the content, as on Wikipedia, with a count badge that comes with the page (commentsCounton the page payload) rather than with the comments — it has to be there before the tab is opened. Every other provider draws itself under the article instead. Both respect the page's ownallowComments, which is the switch in its properties dialog.
Configuration lives in the site's config blob under comments — provider plus a providers map
keyed by module — for the same reasons the analytics configuration does. The settings of the providers
that are not in use are kept, so trying another one and coming back finds a form still filled in.
Three settings, and they answer different questions. features.comments (General → Features,
on by default) is whether the site has comments at all; comments.provider is which one handles them;
and a page's own allowComments (its properties dialog) is whether this page takes them. Enabling and
disabling is General's job alone — the Comments screen only picks which provider, which is why it
offers a radio per provider and no way to choose none. A site starts on the built-in provider, so a
wiki with comments switched on has somewhere for them to go without anybody choosing first.
The master switch is checked by comments.isAllowed, which publicConfigFor and usesBuiltIn both go
through — and deliberately NOT by selectedProvider, which the admin screen reads to show what is
selected: a screen reporting "no provider in use" because the master switch is off would then save that
back as the truth. It says so in a banner instead. selectedProvider still answers empty for a stored
key whose module has been dropped from the installation, which is the one case the screen cannot
produce and has to describe.
Emails
The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test
button — and models/mail.ts is the only place nodemailer is used. MailTemplateData is the
closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did
not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain
code there anyway. A wiki with no SMTP settings is the normal case, which is why isConfigured is
a question callers ask rather than something send() assumes.
No mail is written in English in the code. Every string lives in locales/en.json under
mail.* and is translated by the same CrowdIn pipeline the interface uses, so a locale somebody
translates arrives in the mails without anything here changing. Adding a template therefore means
adding its keys there, and there is no second place copy is kept.
locales.translator(code) is how strings are resolved server-side — a bound { locale, isRTL, t } rather than a t(locale, key) call, because the strings have to be fetched from the db and
everything that renders text does it one locale at a time and several strings at a time. Keys and
{name} placeholders are vue-i18n's, so a translator need not know which side of the wire a string
is rendered on. It is the first server-side translation in the codebase and is not mail-specific;
anything else the wiki writes for a person rather than for a machine belongs in it too.
- Two fallbacks, and they are not the same thing. A code naming a locale that is not installed —
or is not a locale at all — is not used, which is also what stops an unvalidated value off a
request body from putting an entry in the string cache. A locale that IS installed but is missing
the key asked for falls back to
enfor that key alone, because a translation lags the release that added a string and a half-translated locale must not emit raw keys at a reader. - String sets are cached; locale metadata already was.
getLocalesholds the rows, and#stringsForholds the blobs — a few thousand entries the interface re-fetches per request and has no reason to keep, but which a mail reads a handful of keys out of.reloadCachedrops them, which every install and update already calls and which thereloadLocalesevent runs on the other instances of an HA set.
One description, two bodies. A template returns a MailContent — subject, title, paragraphs, at
most one action, footer — and htmlShell and textBody are two renderings of it. Each template
used to write both out by hand, and a string changed in one was a string not changed in the other.
Everything in the description appears in both: the title is a heading in the HTML and a first line
in the text, and a paragraph written under a heading refers to it.
Which language a mail is written in is the caller's answer, not the model's. MailRequest.locale
is what is known about the recipient, and what is known differs at every send site: an account's own
prefs.locale, the locale the browser filling the form was reading the wiki in, or nothing at all.
localeFor then falls back to the site's primary locale — the wiki's own language, which is the
right answer for a mail nobody has a preference on. The admin area's test button sends in the
language the admin area is being read in, so that it also shows what the templates say in it.
prefs.locale is a language preference, not an interface setting. It is edited under Profile →
Info and per-user in the admin user editor, and registration seeds it from the locale the sign-up
form was filled in — which is the only thing a brand new account has to go on, and means the
preference populates itself for anybody who signed up reading the wiki in their own language. What
the INTERFACE is drawn in is a different question with a different answer: on a page it is the
page's own locale, and elsewhere the locale picker's per-browser choice. See the locale block in
App.vue.
The direction is declared three times on purpose. Gmail and Outlook.com drop the <html> and
<body> elements and paste what is between them into their own document, taking any dir on them
with it — so an RTL mail read there comes out left-aligned unless the <td> that survives carries
the direction itself. The bare URL under a button stays ltr either way: a URL is not written in
the language around it, and bidi reordering makes one unreadable.
Templates are not editable by an administrator, and the stub that suggested they were — a
@vue/repl playground behind the experimental flag, wired to a Save button that did nothing and
importing a package the frontend does not have — is gone. Customization is a separate feature that
has not been built; if it is, the shape to keep is sparse overrides on top of the locale strings
rather than a replacement for them, so that a wiki that rewords one sentence keeps getting
translations and improvements for everything else.
Audit log
Every action a person takes is one row in auditLog — userId, clientIP, ts, kind
(page / asset / auth / profile / admin), action, and a meta blob. Read at
/_admin/audit behind the read:audit permission; the retention setting behind manage:system,
because shortening it destroys evidence and that is not the same authority as looking.
- Written from API route handlers, through
audit(req, kind, action, meta)inhelpers/audit.tsand only ever AFTER the work succeeded. That is what excludes the scheduler by construction: a page the git sync imports reachespages.adoptStoredPageby a path that never passes through a route, so it leaves no row. Don't move an audit call into a model to save a few lines — the model is reachable from a job, and the log would start claiming a person did it. - The exceptions are the auth events with no session yet, which record themselves in
models/users.ts: a login, a registration, an email confirmation, a password reset from a link, a forced password change. The account is only identified there, from a credential or a token, and the login case additionally converges six routes (local, provider, passkey, and the 2FA and password-change continuations) on one place. A logout is the mirror image and passes its own actor, since the session is destroyed before the entry is written. AUDIT_ACTIONSinmodels/auditLog.tsis the closed list, grouped by kind, andAuditActionis its union — sonpm run typecheckrefuses an action that is not in it. Each key is also its translation key (admin.audit.actions.<action>) and whatGET /audit/actionsserves the filter from, so adding an action means adding the string too. Keys are unique ACROSS kinds, which is whyforcedPasswordChange(demanded at sign-in) andchangePassword(from one's own profile) are named apart.- What is recorded and what is not. Every mutating route, plus successful logins. Not reads —
page views would bury everything else. Not FAILED logins: that endpoint is open to whoever can
reach the wiki, so recording them would let anybody outside fill the table on demand
(
models/rateLimits.tsis what answers that).requestPasswordResetis recorded only when a link was actually sent, for the same reason. metacarries identity, never payload and never content. A page edit records thepageHistoryversion its change produced — which is whycreatePage/updatePage/movePage/deletePagereturn aPageChangerather than a page — instead of copying the before and after into a second table. Configuration routes record which fields were touched, not what they were set to;securityis the one exception, recorded in full because none of it is a secret and its values are exactly what gets asked about later.sanitizeMetaredacts secret-shaped keys as a backstop, not as the rule.meta.actoris a copy of the email, display name and IP as they stood.userIdison delete: set null, so an entry outlives the account that made it — the copy is the whole reason the row is still readable, and the admin area reads the user column from it rather than from the users table, so a rename does not rewrite history.- Rows are purged, not kept for ever.
purgeAuditLogruns daily offSYSTEM_SCHEDULE, against theaudit.retentionDayssetting (0keeps everything). The floor isMIN_RETENTION_DAYS, 30, and it is not a preference: retention is the one setting whose whole effect is to destroy this table, andmanage:system— the permission that changes it — belongs to exactly the person the table exists to record. Left free, acting and then setting retention to a day would purge the record before anybody had reason to look. So the API refuses anything between 1 and 30, andretentionDays()reads such a value AS 30 rather than honouring it — the second check is what closes the config-file and direct-database routes round the first.
Metrics
A Prometheus exposition, served by controllers/metrics.ts and configured by models/metrics.ts —
the metrics settings blob, the admin area's Metrics screen, GET/PUT /system/metrics.
Everything about it is read per request through the WIKI global, so a change applies at once and on
every instance; nothing here is captured at boot.
- A hook, not a route, because the path is a setting and a route table is fixed at boot. It does
nothing unless the endpoint is enabled AND the path matches, which is exactly what leaves a page at
/metricsserving normally while metrics are off. Turned on, it shadows that page — the one thing the endpoint is allowed to shadow.validaterefuses a path whose first segment starts with_(the server's and the frontend router's namespace) or that names aRESERVED_ROOT_FILESentry, because breaking those breaks the instance from a screen that cannot then be reached to undo it. - It is registered before the SEO hook in
index.ts, and that ordering is load-bearing: a metrics path looks like a page path, so the redirects there would send a scrape to the site's locale prefix or strip a page extension off it. It is registered after the session and API key hooks, whose work it reads. - Anonymous access is per address class — local, private, external (
helpers/network.ts,net.BlockList). An address in a class the operator opened is answered with no credentials at all; every other address must holdread:metrics, as a bearer API key or as a signed-in session. Which is why the bearer hook inindex.tslets the metrics path through as well as/_api/: one place verifies a token. Anything that is not an IP address isexternal, so the unknown case is the strict one. What an address means depends onsecurity.trustProxy— with it off, a wiki behind a proxy sees the proxy for every request, and the admin screen says so. - Two registries, for two lifetimes.
collectDefaultMetricsattaches probes to a registry for the life of the process, so the runtime registry is built once, lazily — a wiki that never turns metrics on carries no probes. The wiki gauges are database counts, so they are built and thrown away per scrape — and are off by default, since a scrape of them costs about a dozen queries. The exposition is line-based, so the two outputs simply concatenate. - Runtime metrics are this instance's; wiki metrics are the cluster's. In an HA set a scrape lands
on whichever instance answered, which is what
wiki_info'sinstancelabel andwiki_start_time_secondsare for.
GraphQL is being removed
An earlier iteration of 3.x used GraphQL/Apollo. All of it is deprecated — there is no GraphQL
server left in backend/, and APOLLO_CLIENT is not defined as a global, so any call still going
through it throws.
Nothing calls it any more. The last one was pages/AdminNavigation.vue, an admin screen that was
already experimental, disabled in the nav and broken twice over (its save() went through
APOLLO_CLIENT.mutate, and it called this.$store.commit(...) nine times in a <script setup> file
with no Vuex store anywhere in the app). It was deleted along with its route when manage:navigation
became a page rule — navigation is edited from the sidebar of the page it belongs to, through
api/navigation.ts. grep APOLLO_CLIENT frontend/src now finds nothing.
If you find yourself wanting a GraphQL endpoint, add a REST one under backend/api/ following the
schema + permissions conventions above instead — sites/:siteId/images/:kind, which replaced the logo
and favicon upload mutations in AdminGeneral.vue, is an example of doing exactly that.