Releases: Dhanabhon/tome-cms
Release list
TomeCMS 1.5.0
TomeCMS 1.5.0
Date: 2026-09-30
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.5.0 takes the operating system out of the admin: every list, date, time and colour is chosen
in the admin's own controls, and no form lets the browser draw its own warning. Save buttons now say
what they are doing, the File Manager takes several files at once into a folder you choose, and a post
can keep its cover off the top of the article. /api/v1 gains one field, showCover; nothing is
renamed or removed.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. 1.5.0 brings one
migration,027_post_show_cover, so the updater takes a full backup (PostgreSQL and the bucket)
before it, and the site is offline a little longer than for an update without one. - A build from source upgrades in place by running the deploy helper again, which runs the
migration.
The migration adds posts.show_cover, a boolean not null default true: every existing post keeps
showing its cover. There is no new setting and no new runtime dependency.
deploy/cloud-init.yaml now installs 1.5.0.
No system pickers
The design is in the spec. In short:
- Lists open the admin's own list everywhere, including the Posts and Pages language filters and
the Redirects article list. A list that filters a page applies on a choice (a click or Enter), not
while arrowing through it. - Dates and times have a picker of their own: a month grid in the owner's calendar, with hour and
minute fields, Clear and Done. It is driven by the keyboard (arrows, Home/End, PageUp/PageDown,
Enter), closes on Escape or a press outside, and keeps its time row and Done in view on a short
phone screen. It serves a post's and a page's publish date, Maintenance's return time and a slide's
start and end. - Colours in a plugin's settings are typed as hex beside a swatch.
- Forms take
noValidate: an empty name or address is told in the admin's words, beside the field,
and the field takes the focus. - Radios and checkboxes use the accent colour instead of the browser's blue.
The file chooser behind "Upload" is the one exception: the web does not let a page replace it. A unit
test fails on a native <select>, date, time or colour input, or a form without noValidate, in the
admin.
The admin
- Save buttons carry their state: a spinner while saving, "Saved" after, and "Save" again once
something changes. The button keeps one width, and a hidden status line says the same words to a
screen reader. The words beside the button are gone. - The row menu on Posts and Pages, and the File Manager's folder menu, float with a shadow, set
Delete apart, close on a press outside or Escape, and one opens at a time. - A white main column. The screens sit on
--color-paper; the sidebar stays on the cream page
behind them. - The Plugins notes ("Where plugins come from", "A plugin cannot lock you out") sit under the plugin
grid at their own height with 16px headings; the admin's accent bars are 2px.
The File Manager
Choosing files opens an upload dialog. Pick the folder (the one on screen by default), and the files go
up two at a time, each with its type, size and progress. A file the library cannot take is told at once
and never sent; a file that fails in transit has its own Retry, and the others carry on. Stop cancels
what is still running, and the library opens on the chosen folder with whatever landed. Choosing a file
from a post (a cover, an image) still takes one file, as before. The upload button loses its focus ring
after a file is chosen.
The cover in the article
A post's settings have "Show the cover at the top of the post", on by default. Turned off, the cover
leaves the top of the article only: the home page card and a shared link's og:image still use it.
Each language's edition has its own switch, and a translation or a duplicate starts with the source's
value. /api/v1 posts carry showCover.
The public themes
Paper's footer sets the TomeCMS credit as its own line at the right; on a phone it wraps under the
copyright at the left.
For theme authors
articleCover(post) in src/lib/post-cover.ts returns the cover to show at the top of an article (or
null) and the body to render; both bundled themes use it. A theme that reads cover_image directly
keeps working and shows the cover whatever the switch says.
Testing
tests/e2e/admin-150.spec.tscovers the save button's states, the row menu's closing, the date
picker by keyboard, the multi-file upload with a refused file, a retry and Stop, and the cover switch
withog:image.- Unit tests cover the calendar arithmetic, popover placement, the upload queue, hex colours, the save
state, the row menu's closing rules andarticleCover;tests/unit/admin-surface-tokens.test.ts
guards against native pickers and forms withoutnoValidate. - The migration runs in
npm run test:operations:update, andnpm run check:inventorylists it.
TomeCMS 1.4.0
TomeCMS 1.4.0
Date: 2026-09-30
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.4.0 changes the shape of the admin. Every screen but the editor now reads as an editorial page
instead of a set of equal boxes, and on the public site the search box moves onto the row of categories.
Colours, fonts, screens and what each screen does are unchanged. /api/v1 is unchanged.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency, so an updater of 1.3.0 or later backs up the
database alone before it. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.4.0.
The admin
The design is in the spec. In short:
- A masthead, not a top bar. From 64rem up there is no bar over the screen. Each screen opens with a
small uppercase label naming its group, the title in the display face at 40px, a line about the
screen, its actions on the title's baseline, and a rule. Phones keep a thin bar with the menu. - The sidebar sits on the page. It is no longer a white column. The site's name is a line under the
logo, group names are small labels, counts are plain figures, and the screen you are on is ink with a
2px green bar beside it rather than a filled row, since no two surfaces in this palette are far enough
apart to show a state. - Sheets, not cards. The form screens (Settings, Maintenance, Profile, Security, System) are stacks of
sheets separated by a rule. Where there is room (a 53rem stack) a sheet is two columns: its name and a
line about it on the left, its fields on the right, no wider than 34rem. On a tablet it is one column.
The save bar is a row on the page's lower edge with its state in words beside the button. Settings and
Maintenance share a General / Maintenance tab row. - Figures on Stats. Views, reads and the read ratio are one ruled row of large figures; a change is
green, a note is not. The chart, the share lists and the table of posts and pages lose their boxes;
a share is a thin green line under its label. - Three empty states. A screen with nothing made yet shows a short block with the one action that
starts it. A tab or filter that finds nothing says so in a line, with a link back to all. An empty part
of a form is a muted line. - Smaller things. Card and row titles use the display face; status is a dot and a word, with no
pill; numbers are set in columns; a list states its timezone once, under its title, instead of on
every card; spacing follows an 8px rhythm, with 32px added to the admin's steps.
Removed
- The admin's post search. The field in the top bar searched post titles only but sat on every
screen, so it read as a search over everything. It is gone, with the title filter on Pages. Aqin
an address the list already had still filters it. - "Apply filters". Posts and Pages keep their status tabs and a language filter at the end of the tab
row. Chosen with the mouse, a language applies at once. With the keyboard, arrowing through the
languages does not leave the page; Tab to "Show this language" to apply the choice. Without JavaScript
that button is the way for everyone.
The public themes
On Paper, the search box moves from above the category pills to the right end of their row: a quiet
field 15rem wide that grows to 20rem while it has focus, with the magnifier inside it as the submit
button. On a phone the field takes the full width above the pills. Plain does the same on its own row
and keeps its text button. The results line, the empty state, noindex on a results page and the
paging are unchanged.
For theme authors
Nothing in the theme contract changed. The bundled themes' markup did: Paper's .post-search__field
wrapper is gone and the form now sits inside .post-controls with nav.post-filter; Plain's form sits
inside .plain-controls. A theme that copied either bundled theme's home page keeps working as it is.
Testing
tests/unit/admin-surface-tokens.test.tspins the new shapes, andtests/unit/theme-contrast.test.ts
the colour pairs they lean on, in both themes.tests/e2e/admin-shots.spec.tsshoots every admin screen at 1440, 768 and 375 pixels in both themes and
measures the boxes the design makes claims about. It runs only when asked:
ADMIN_SHOTS=<label> npm run test:e2e -- tests/e2e/admin-shots.spec.ts --project=desktopwrites to
.superpowers/admin-shots/<label>/.- The language filter and the "Show all" link from an empty tab are covered in
tests/e2e/select-in-dialog.spec.ts.
TomeCMS 1.3.3
TomeCMS 1.3.3
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.3.3 makes Paper's reading progress bar and fading hero work on a real site, and cuts the
database queries of every public page by more than half. /api/v1 is unchanged.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.3.3.
The reading progress bar and the fading hero
Paper's "Reading progress bar" and the "Fading" choice under "Slides change by" draw from the scroll
position with a scroll-driven animation, and neither ever moved on a production site. The owner of the
test site ticked the bar in Chrome and saw nothing. On that page the bar was rendered, and its
animation-name was none.
The build minifies CSS, and the minifier folds animation and animation-timeline into one
animation shorthand with the timeline inside it. Chrome does not accept a timeline in that shorthand,
so it drops the whole declaration. The browser tests passed because they run against the development
server, which does not minify.
Both animations are written as longhands now (animation-name, animation-timing-function,
animation-fill-mode, animation-timeline), which the minifier keeps apart. A unit test minifies every
theme stylesheet with the same minifier and fails on a timeline inside an animation shorthand. On a
local production build the bar now fills in proportion to the scroll.
Fewer queries per page
1.3.2 made the home page measure reading time once per post, which was 2.7 times faster locally with
Thai posts but did not move the test server (17.5 requests a second before, 18.2 after). Measuring
there instead showed the CPU split between the application and PostgreSQL, and a home page sending 16
queries: the site's settings six times, and each plugin's settings in its own query.
getSiteSettings and the enabled plugins' settings are now read once per request, through a scope the
middleware opens for GET and HEAD requests only; the plugins are read in one query, and a theme's
settings come from the same row. Nothing is remembered past the request, so an edit is seen by the next
one and there is nothing to invalidate. The home page now sends 6 queries, a post's page 5 (from 14), and
the content API 4 (from 5).
Locally, where PostgreSQL runs on other cores than the application, a post's page went from about 660
to 840 requests a second and the home page from 246 to 271. On a one-core server, where both share the
CPU, the saving should count for more; it has not been measured there yet.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass.npm run test:unit: 636 tests, all passing, among them the per-request memo (one read per request,
none kept across requests or outside one, a failed read not kept) and the minified-CSS check.- Integration tests on a real database for plugin settings, the popup, published queries, search and
maintenance pass, and so do the browser tests of the public plugins, theme settings, the brand, the
theme toggle and maintenance. - On a local production build the reading progress bar was seen to fill as the page scrolled.
- Not yet measured on the managed server.
TomeCMS 1.3.2
TomeCMS 1.3.2
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.3.2 makes the Paper home page about two and a half times as fast for a site written in
Thai. Nothing else changes, and /api/v1 is unchanged.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. From 1.3.0 with an upgraded updater, the
backup is the database alone. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.3.2.
What was slow
Measured on the 1 GB test server, the home page answered 17.5 requests a second, four at a time,
about a quarter of what the same page did with English test posts. Every card shows "N min read",
and the page worked that out on every request by splitting the whole text of each post into words
with Intl.Segmenter. Thai is written without spaces, so that runs a dictionary word breaker over the
entire post: about 4.4 ms for a long Thai post on a fast machine, against 2.2 ms for English, for
every card, for every reader.
The fix
The reading time of a post changes only when the post does. It is now worked out once per saved
version (the post's id and last update) and remembered in memory for the newest 1,000 versions. An
edit is measured afresh; nothing else is.
Measured locally, the production build with 60 long Thai posts: the home page went from 92 to 246
requests a second, and the Node CPU it took from 10.9 ms to 3.4 ms a request. A post's own page, which
never measured reading time, is unchanged at about 660.
What remains of the home page's cost is mostly reading full post bodies from the database for cards
that show only a title and an excerpt. That is a larger change, left until it is measured to matter.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass.npm run test:unit: 633 tests, all passing, among them that a post's reading time is measured once
per version and again after an edit.- The browser tests of the Paper home page and of search pass.
- Not yet measured on the managed server: the home page on the test server after this update.
TomeCMS 1.3.1
TomeCMS 1.3.1
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.3.1 changes documentation only. The application is the same as 1.3.0, and /api/v1 is
unchanged. It is also the first release a 1.3.0 server installs with the lighter backup.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. On a server that runs 1.3.0 and whose
updater was upgraded to 1.3.0, this update backs up the database alone, since no migration is due,
and afterwards the "Last update" card says how long the site was offline. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.3.1.
Documentation
- Upgrading the updater on a server installed before 1.0.2: such a server has no
/opt/tome-cms-src, so the release is cloned there first. The first real run of
npm run updater:upgrade, on the test server, found that gap, and the clone worked. - What the server needs now says that a managed install runs a personal site on 1 GB of memory
with 2 GB of swap. The test server (1 GB, one shared vCPU) has run a managed install with real
content since 1.0.1. With the site in use it had about 400 MB available and swap in use; its home
page answered about 17 requests a second, four at a time, with no failures. The minimum stays 2 GB
for a busier site and for a build from source. - The 1.0.0 acceptance record has its first real-server row: the managed install on the test
server, its updates from the admin up to 1.3.0, the updater upgrade from 1.0.0 to 1.3.0, and
npm run restore:checkverifying both the newest and the oldest updater backup in disposable
projects.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass.npm run test:unit: 631 tests, all passing.- Not yet run on the managed server: updating it from 1.3.0 to 1.3.1 is the first database-only
backup on real hardware.
TomeCMS 1.3.0
TomeCMS 1.3.0
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.3.0 makes updates without a migration keep the site offline for less time, shows how long
an update kept it offline, and lets the managed updater itself be upgraded. /api/v1 is unchanged.
Upgrading
-
A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. An updater of any 1.x version installs it,
with a full backup as before. -
Then upgrade the updater, once, from a checkout of
v1.3.0, as
Upgrading the updater
shows. The site stays up while it runs:cd /opt/tome-cms-src git fetch --depth 1 origin tag v1.3.0 git checkout --detach v1.3.0 npm ci sudo npm run updater:upgrade -- --dry-run sudo npm run updater:upgradeA server installed with 1.0.1 or 1.0.2 also gets the updater fixes of 1.0.3 this way (the stop
timeout and the rollback that waits for it), which it never had. Nothing requires it: without it the
next updates work as before, with a full backup. -
A compose file edited by hand is replaced by the release's own. The command says so when it
was different and keeps your copy beside it. The only change since 1.0.0 isinit: trueunder
app:, which a server that followed the 1.0.2 troubleshooting entry already has. -
A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.3.0.
A lighter backup when no migration is due
The updater stops the application, backs up, migrates and starts it again, and the site is offline
from the stop to the end. The backup copied the database and every file in the media library, so a
site with a large library was offline for as long as that copy took, even for an update that changed
nothing in the database.
From 1.3.0 the updater first lists the migrations the installed application ships and compares them
with the new release's, before the site goes offline. When they are the same, no migration will run
and no table changes, so it backs up the database alone. When a migration is due, or the list cannot
be read, the backup is full as before. The backup runs inside the installed application, whose own
backup command has to know --database-only, so it takes effect from an update that starts on 1.3.0
or later: 1.3.0 to 1.3.1, not 1.2.1 to 1.3.0.
A database-only backup has no objects/ folder, and its manifest.json says "scope": "database".
npm run restore:check checks it and says so. To restore one, restore the dump and leave the bucket as
it is, as Backups and restore now says. A full
backup is written exactly as before, so an older restore check still reads it.
How long the site was offline
With an updater of 1.3.0 or later, each update records when every phase began. The "Last update"
card on "System" then says how long the site was offline, from maintenance to the end, and what the
backup held and how long it took, such as "Database only, no migration was due (3 s)". The record is
private to the server and read through a new updater route, /v1/timeline; the updater's status keeps
its old shape, so an application from before 1.3.0 reads it as before.
Before this release the only measurement came from daedalus, the test server: its 1.2.0 update took
80 seconds from start to end, download included, with backups of about 9 MB each.
Upgrading the updater
The updater runs on the host and was built once, when the server was installed; updating from
"System" replaces the application image only. sudo npm run updater:upgrade:
- runs only as root, from a clean checkout of the exact release tag, and refuses to go back to an
older updater; - builds the updater, stops the service, checks again that no update started meanwhile (and starts
the service and stops if one did), then replaces/opt/tome-cms/updater, the systemd unit and
compose.managed.yamlwith the release's own, keeping the old ones with.previous-and the time; - starts the service and waits up to 30 seconds for it to answer with its new version; if it does
not, it puts every old file back and starts the old updater, and a restore step that fails does not
stop the ones after it.
--dry-run checks and says what it would replace, and changes nothing.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass.npm run test:unit: 631 tests, all passing, among them: the job timeline and backup kind kept apart
from the public status, a job file written by an earlier updater still read; the backup kind chosen
before the site goes offline, full for an installed application before 1.3.0, for a migration, or
when the installed image cannot say its migrations; a backup of the wrong kind refused before the
image changes, both ways; the database-only manifest; and the upgrade's refusals and file swap,
including an update that starts while the new updater builds and a restore step that fails.npm run test:operations:update(real containers, a full update and a rollback): 9 tests pass.- The upgrade was rehearsed in a Linux container with a stand-in for systemctl: an updater built from
v1.2.1 answered 1.0.0, the upgrade refused while a job was in progress, the dry run changed nothing,
the upgrade replaced it with 1.3.0 and kept the old files, and a new updater that crashed at start
was replaced by the old one, which answered again. - A browser test shows the offline time and the backup kind on "System".
- Not yet run on the managed server: installing 1.3.0, upgrading the updater there, and the first
database-only backup (1.3.0 to the next release) are their first runs on real hardware.
TomeCMS 1.2.1
TomeCMS 1.2.1
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.2.1 fixes three small things found while reviewing 1.1.1 and 1.2.0. There is no new
feature, and /api/v1 is unchanged.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.2.1.
Fixed
- A re-check after the session ended. With the Cloudflare Turnstile plugin on, a passkey
check on "System", on "Security" or for new recovery codes, made after the session had ended,
said "The sign-in check was not passed" and suggested switching the plugin off on the server.
Only the sign-in page can pass that check, so the advice could never help and pointed at
disabling a security control. It now says "The sign-in session expired. Reload the page and try
again." - Who skips the sign-in challenge. Since 1.1.1 an owner who is signed in is not asked for the
challenge again. The server now skips it only for a session that belongs to the installed owner,
rather than for any session that exists. No session other than the owner's reaches that point
today; this keeps it so if one ever does. - Plain's secondary text. The Plain theme read
--color-ink-muted, which no stylesheet
declares, so its tagline, dates, excerpts, category filter, footer and search status were drawn
in full ink. They use--color-mutedagain, whose contrast on every page surface is already
pinned at 4.5:1 or more in both colour schemes.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass.npm run test:unit: 612 tests, all passing, among them the wording of a refused re-check in
both languages, that the challenge skip is keyed on the owner's identity, and a new test that
fails on any theme stylesheet reading a custom property nothing declares.- The browser tests for re-authentication with Turnstile on and for search in both themes pass;
the Plain homepage was looked at with its muted text restored.
TomeCMS 1.2.0
TomeCMS 1.2.0
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.2.0 lets readers search the posts, from the homepage of both bundled themes and from the
public API. Every 1.1.x feature keeps working. The one change to /api/v1 is an optional
parameter, q.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. An update started from 1.1.1 or later
works with the Cloudflare Turnstile plugin on. - A headless site does not have to change.
qis optional, a list without it answers as it
did, and a cursor issued before this release still opens. - A theme of your own, in a fork, must accept the new
queryprop ofHome, which the route
always passes. Themes says what a
Hometemplate does with it. Paper and Plain already do. - Behind a proxy, the limit on searching counts each sender by the address the proxy writes in
X-Forwarded-For, as the limits on signing in have since 1.1.2.
deploy/cloud-init.yaml now installs 1.2.0.
Search
- The box. Paper and Plain show a search box above the posts on the homepage. It is a plain
form, so it works without JavaScript, and the words stay in the box. Above the results a line
saysResults for “…”with a link, "Clear search", back to the whole list. Paper hides its hero
while results are shown, so they come first. When nothing matches, the page says so, and the
reader's words are always printed as text. - What it finds. The published posts of the page's language that contain every word typed,
wherever the word is: the title, the excerpt or the text. Case does not matter, and a word
matches inside longer words, which is what makes it work in Thai, where words are not separated
by spaces. A search takes at most five words and 100 characters.%,_and\are only
themselves. - What counts as text. Bold, italic, a link or a colour inside a word does not cut it, so a
Thai phrase is found across a bold word in it. A paragraph, a list item, a table cell or a line
break does end a word. Class names, addresses and tag names are not text, and an entity such as
&is the character it shows, so a search forQ&Afinds it. - The results page. It is kept out of search engines (
noindex, follow) and its title says
what was searched for. "Older posts" and the endless scroll keep the search, and choosing a
category leaves it, in the list and in the box. - The API.
GET /api/v1/content/posts?q=takes the same words.links.nextkeepsq, the
signed cursor is tied to it, and an emptyqor one with no word in it is a400. The OpenAPI
document and the API overview
describe it. - Limits. A search reads every published post of the language, so a sender may make 60 a
minute, and no more than two searches run at once. Past either, the page answers a plain
429, and the API a problem response with aRetry-Afterof 60 seconds. A list with no search
is never held back. A headless site that fetches from its own server is one sender for all its
visitors, so it should let the visitor's browser call the API or keep what it fetched for a
minute.
For theme authors and headless sites
ThemeHomePropsgainsquery: string | undefined, the words that were searched for. AHome
template draws arole="search"form whose field is namedq, says what was searched for and
how to leave the search, carriesqon its link to older posts, and draws the results before
any hero. The route narrowspostsand sends thenoindex; the template needs nothing for those.- Nothing was removed or renamed.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass, links included.npm run test:unit: 609 tests, all passing, among them the splitting and escaping of a search,
the query the API accepts and refuses, that a list with no search hashes its cursor as before,
and that only two searches run at once.- A new integration test on a real database covers a search over the title, the excerpt and the
body; Thai and English apart; drafts and scheduled posts left out; tags, class names and
addresses not searched; entities and the%,_,!and\characters; a Thai phrase across a
bold word, and paragraphs, list items and cells kept apart; several words, categories and
cursors; and the API over HTTP, including that a refused query costs the sender nothing. - A new browser test drives both themes at 375 and 1440 px: the box, the results page, nothing
found, a hostile string with quotes and$&, no JavaScript, the older posts keeping the search,
a category leaving it, Thai, no sideways scroll, and the limit. The existing browser tests of the
homepage, the theme settings, the public plugins and Stats still pass. - The screens were looked at in a browser, in both themes, at both widths.
- Not yet run on the managed server: updating it to 1.2.0 is its first run on real hardware.
TomeCMS 1.1.2
TomeCMS 1.1.2
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.1.2 makes the limits on signing in, recovery, installing and updating count each
visitor, not the reverse proxy in front of the application. Nothing else changes, and /api/v1
is unchanged.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. Caddy already sends what the change
reads. The Cloudflare Turnstile plugin no longer needs to be switched off for this update if
the site is on 1.1.1; it does if the site is on 1.1.0 or earlier. - Behind another proxy, make it set or append
X-Forwarded-For, as
the reverse proxy
says. A proxy that leaves it out keeps the old behaviour, one count for every visitor. A proxy
that passes a visitor's own header through untouched, which nginx does without a line for it,
lets that visitor choose the address the limits count, so set it before updating. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.1.2.
What was wrong
Astro's node adapter hands the application the address of the socket, and behind the proxy that
is the proxy, Docker's bridge on a managed install, for every visitor. It believes
X-Forwarded-For only when the site's domains are listed when the image is built, which one
official image serving every domain cannot do. So each limit was a single count for the whole
internet: 10 sign-in attempts in 15 minutes, 5 recovery attempts in 30, 8 for installing, 6 update
checks in 10 minutes and 3 installs in 30. Ten failing sign-in requests from anyone kept the owner
out for up to fifteen minutes (a passkey cannot be guessed that way, but the owner could not sign
in), and the Cloudflare Turnstile plugin was told the proxy's address as the visitor's.
The fix
senderAddress, which Stats has used since 0.11 to count readers, now serves every limit and
the sign-in challenge. It takes the lastX-Forwarded-Forentry, the one the proxy wrote, and
only when the connection comes from the proxy's side: loopback,10.0.0.0/8,172.16.0.0/12,
192.168.0.0/16orfc00::/7. From anywhere else the header is the sender's own words and is
ignored. The limit takes only an addresssenderAddressreturned, which is a type, so a route
that hands it the peer's address does not compile.- A visitor on IPv6 counts by their /64, as Stats already did, so cycling through the addresses
of one block buys no fresh count. - Rows of the limit table older than an hour are swept, once a minute at most. With a count per
visitor the table would otherwise grow with every address that ever asked. security.allowedDomainsstays unset. The helper depends on that, a test now says so, and the
comment on it says why setting it could not be the fix.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass, links included.npm run test:unit: 603 tests, all passing, among them the proxy-side rules, the /64 key, and
that the app never lists its domains.- A new integration test on a real database drives the sign-in route from Docker's bridge: ten
attempts from one visitor pass and the eleventh is refused, another visitor behind the same
proxy is not limited, a made-up header from a public address buys no fresh count, one /64 counts
as one visitor however its addresses are spelled, and a row from two hours ago is swept. Before
the change the second visitor was refused with the first. - The production build,
node dist/server/entry.mjs, behind a real TCP proxy that overwrites
X-Forwarded-Foras Caddy does: the same result over HTTP, and a header the client wrote itself
was ignored. - Not yet run on the managed server: updating it to 1.1.2 is its first run on real hardware.
TomeCMS 1.1.1
TomeCMS 1.1.1
Date: 2026-09-29
Status: Stable release; tagged and published as a GitHub release
TomeCMS 1.1.1 fixes installing an update from the admin on a site that has the Cloudflare
Turnstile plugin switched on. Nothing else changes, and /api/v1 is unchanged.
Upgrading
- A managed install updates from "System" in the admin, as
Updating describes. There is no
migration, no new setting and no new runtime dependency. - On 1.1.0 or earlier with Turnstile on, switch "Cloudflare Turnstile" off under "Plugins"
before you press install, and on again afterwards. The running version is the one that asks for
the passkey, so this one update still needs it.
Troubleshooting
has the steps. An update started from 1.1.1 does not. - A build from source upgrades in place by running the deploy helper again.
deploy/cloud-init.yaml now installs 1.1.1.
What was wrong
On 1.1.0 and earlier, with the Turnstile plugin on, confirming an update with your passkey on
"System" failed with "The passkey check did not finish. It may have been cancelled. Try again.",
although the passkey was fine. "Verify and create new codes", under "Recovery codes" on "Security", failed the same way. Asking for a passkey once
more goes through the same request as signing in, the server put the sign-in challenge in front of
it, and only the sign-in page can pass the challenge, so the request came back 403 with
challenge_refused. On "System" the failure was also written to the line that reports the release
check, so "Release availability" turned red and said "Check unavailable" although the check had
worked.
The fix
- The sign-in challenge is asked for when there is no session. An owner who is already signed in
is confirming a passkey again, not signing in, and is not who the challenge keeps out. The rate
limit on sign-in attempts still applies to every such request, and a caller with no session meets
the challenge as before. - "System" reports a failed passkey check as the other passkey screens do: a refused challenge, a
rejected origin, a rate limit, a passkey the site does not know, a server fault and a lost
connection each get their own sentence, in red beside the buttons. "Release availability" keeps
saying "Update available", because the release check did work.
Validation boundary
npm run checkpasses, and the documentation site's check and build pass, links included.npm run test:unit: 602 tests, all passing.- A new browser test,
tests/e2e/reauth-with-challenge.spec.ts, runs on a disposable stack with a
passkey registered and Turnstile switched on. It confirms an update on "System" and makes new
recovery codes, and it checks that a caller with no session is still refused, whether the caller
sends no cookie, a made-up one, or the cookie of a session that has just ended. It also checks that
a refused passkey is named on "System" while "Release availability" stays true. Before the fix,
confirming an update and making new recovery codes failed with403 challenge_refused, and a
refused passkey had no message of its own; after it, all four tests pass. - Not yet run on the managed server: installing this update there is its first run on real
hardware.