Architecture
Deep reference: CLAUDE.md (sections
“Routing”, “The admin panel”, “Content-Security-Policy”)
Processes and deployment
Section titled “Processes and deployment”browser ──HTTPS──> nginx (TLS, Let's Encrypt) ──HTTP──> app (Node 20, Express 5) ──> SQLite + uploads │ ├─ BackupScheduler (timer) ──> BACKUP_DIR ──> S3 bucket (optional) ├─ UpdateChecker (timer) ──> GitHub releases API ├─ trash / upload / audit / session purges (timers) └─ scheduled posts, every 60 s (timer)docker-compose.prod.yml:app(the GHCR image named byAPP_IMAGE),nginx(nginx/templates/default.conf.template,SITE_DOMAINsubstituted at start) andcertbot(renewal loop).DATA_DIR=/dataandBACKUP_DIR=/backupsare bind mounts outside the code directory.src/server.jsis the entry point: loads and validates the environment (src/config/env.js, fail-fast), opens the database (src/db/index.js— WAL,foreign_keys = ON, migrations), builds the services and hands them tosrc/lib/createServer.js, which callscreateApp(...); itsstart()writesDATA_DIR/.server.lock(soscripts/restore.jscan refuse to run under a live server), listens, then starts the timers. On SIGTERM it stops taking connections, lets open requests (≤ 10 s) and a running backup (≤ 8 s) finish, removes the lock and exits;stop()does the same without exiting, for tests.- Everything that is not a request (backups, update check, purges, publishing scheduled posts)
runs on unref’d timers inside the same process. The admin router’s own chores (purges, the
scheduled-post tick of
src/admin/lib/publishScheduled.js) run once when it is built and then throughsafeInterval, so a test app runs them once too — against an empty database. Tests never start them:buildTestApppasses no scheduler/checker unless a test injects a fake.
Request flow
Section titled “Request flow”src/app.js mounts, in this order:
GET /health— before logging, helmet and static files; JSON, no session,no-store.- Request logger (
pino-http),helmet()with the site-wide CSP,Permissions-Policy. GET /theme.css(brand colours from settings),GET /site.css+GET /site.js(the public stylesheets and scripts joined at boot,src/routes/assets.js), thenexpress.static('public'), then/uploads/*fromDATA_DIR/uploads.express.urlencoded(10 kB, skipped for/admin, which parses its own), thencreateLocals— resolves settings, menu and site identity intores.localsand definesres.renderPage()synchronously, so the error handler can always render.- Public routers:
seo(robots.txt,sitemap.xml,/<feed>.rss) →search(/poshuk) →site(fixed pages, then/:slugand/:slug/:childfor pages of typegallery,people,posts, which callnext()for anything else) →contact(POST /send-message). /admin—createAdminRouter()(below).documents— the catch-allGET /:pageNamefor info pages andGET /:pageName/zvit(the printable school-year list of a page’s posts).notFoundHandler,errorHandler.
Rendering: a route calls res.renderPage('<view>', data) (src/lib/renderPage.js); the view
renders to a string and is wrapped in src/views/layouts/base.ejs (head, top bar, header, menu,
footer partials). The admin’s draft previews use the same res.renderPage with noIndex: true.
The admin router
Section titled “The admin router”src/admin/index.js builds an independent chain: express-session over the
SQLite sessions table (cookie <SITE_SLUG>.sid, Path=/admin, Secure in production, 12 h
rolling, 7 days at most) → flash → renderAdminPage → urlencoded (1 MB, 413 page) → CSRF
(double submit; an upload route is built with
multipartRoute() and registered by mount(), and verifies the token after multer —
src/admin/lib/multipart.js) → auth router (login/logout, rate limited) → setup wizard (/admin/setup, only while the
users table is empty) → requireAuth (re-reads the user every request) → can() exposed to
views → audit middleware → cache invalidation after every non-GET → feature routers → admin
404/500.
Conventions every admin route relies on:
- Permissions: the role matrix is
src/admin/lib/permissions.js(ACTIONS). Routes enforce it withrequireRole('admin')/requireCan(action); templates callcan(action)only to hide what would be refused anyway. - Audit log:
src/admin/lib/audit.jsrecords a successful action when the route sets a success flash (req.flash('success', …)). A write that answers without one (a reorder, a partly failed upload, a JSON answer) records itself withreq.audit(summary). - Stores and forms: the SQL of an entity is
src/admin/stores/<entity>.js(createXStore(db), statements prepared once, multi-statement writes in one transaction); the route validates, calls the store and renders. Templates read the camelCase form oftoForm(row), never a raw row; a posted form is read by the entity’sfromForm(body). - Refused forms come back with 400, the messages and what was typed
(
admin/lib/crud.js renderForm);flashis for success and for refusals that are not about the input. Handlers may beasync— Express 5 forwards a rejection, so there is notry/catchthat only callsnext(err).
Layers and where things live
Section titled “Layers and where things live”| Concern | Location |
|---|---|
| Configuration & validation | src/config/ — env.js, limits.js (upload limits, page sizes, the multer factory), siteSettings.js, theme.js, pageTypes.js, homeBlocks.js, libraryGroups.js, transparency.js (the article 30 checklist), siteTemplates/*.json |
| Database | src/db/ — index.js (open + migrate), migrate.js, migrations/*.sql |
| Public read model | src/content/SqliteContentRepository.js — the only content reader for the public site; caches the sitemap list, dropped by content.invalidate() |
| Pure helpers | src/lib/ — Markdown rendering, dates, formatting (format.js), upload URLs, the settings accessor (settings.js), the page renderer, file usages, menu tree, SEO helpers, search text, backup utils, changelog parser, site template |
| Public routes & views | src/routes/*.js, src/views/{layouts,partials,pages}/ |
| Admin | src/admin/ — routes/ (HTTP; routes/pages/ is the page editor, one file per tab), stores/ (the SQL, one module per entity), views/, lib/ (permissions, audit, multipart, CRUD helpers, form readers, trash, page editor, setup), middleware/ |
| Services with side effects | src/services/ — FileStorage, MailService, BackupScheduler, OffsiteBackupStore, UpdateChecker, backup (the archive; scripts/backup.js is its CLI) |
| Browser assets | public/css/ (tokens, base, components/*.css), public/js/, public/admin-ui/ (admin CSS/JS, Markdown editor) |
| CLI scripts | scripts/ — create-user.js, backup.js, backup-verify.js, restore.js, backfill-image-sizes.js, changelog-section.js, check-coverage.js, deploy-env.sh, server-setup.sh, init-letsencrypt.sh; each .js answers --help |
| Tests | test/ — mirrors src/ loosely; helpers in test/helpers/ |
Cross-cutting rules
Section titled “Cross-cutting rules”- CSP without
'unsafe-inline'. No inline<script>, nostyle="…"attribute, nosetAttribute('style', …). Add a CSS class or an external file instead. Setting a property through the CSSOM (element.style.x = …,style.setProperty()) is not governed by CSP and is allowed. JSON-LD blocks are data and are allowed. The exact rule:CLAUDE.md→ “Content-Security-Policy”. - Uploads only through
FileStorage: content-sniffed withfile-type(never the client MIME or extension), images re-encoded to WebP viasharpwith a thumbnail,remove()refuses a file still referenced (the reference list issrc/lib/fileUsages.js).src/servicesandsrc/libnever requiresrc/admin. - Minors’ data (authors, people, photos) is shown in its public form only through
src/lib/authors.js— name display and photo visibility are decided there, once. - No installation data in code. Names, addresses, colours, menus are settings or rows.
test/neutralTemplate.test.jschecks a fresh database renders nothing invented. - Ukrainian UI, English code. Every user-facing string is Ukrainian; identifiers, comments,
commits and these docs are English. Comments in
public/ship to browsers — keep them short and generic.