Skip to content

Fix/panel row steps - #19

Merged
i7Gamer merged 41 commits into
developmentfrom
fix/panel-row-steps
Aug 19, 2026
Merged

Fix/panel row steps#19
i7Gamer merged 41 commits into
developmentfrom
fix/panel-row-steps

Conversation

@i7Gamer

@i7Gamer i7Gamer commented Aug 19, 2026

Copy link
Copy Markdown
Owner

No description provided.

i7Gamer and others added 30 commits August 18, 2026 09:27
…d entry

The MSI registered "Timo" as its Manufacturer, which is what Add/Remove
Programs shows as Publisher, and offered no link at all from there back to
the project - the same standing a repackage of the installer would have.

None of this is the "Unknown publisher" line on the UAC prompt. That line is
read out of the file's Authenticode signature and nowhere else, so it stays
until the MSI is signed; the comment beside the new properties says so, since
the two are otherwise easy to mistake for each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stability card printed "0.8 M" in German where every other locale prints
"0.8 ms". The label is right and the figure is right; latest.jitter_unit is what
is wrong, and it is wrong in three of the sixteen translations - "M" in German,
"m" in Spanish and "Mms" in French. Reported from a real card in German.

They read as a unit that has been through a translator: "ms" is a symbol, not a
word, so nothing should have happened to it. latest.ping_unit sits two lines
above in the same file and survived in all three, which is what makes this a
slip in those entries rather than a policy about how the app names milliseconds.

en.json is the Crowdin source and the rest are pulled from it, so the same three
strings want correcting there as well or the next sync brings them back.

Ukrainian carries no jitter_unit at all and falls back to the English "ms" while
its ping reads "мс". That one is a missing translation rather than a broken one,
so it is left to Crowdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… everywhere

Three reports from one window, and they are three views of the same fault: at
1263px the latest test stood glyphless between two cards holding exactly the
same 319px of list; at 859px it alone stated a smaller figure than the card
beside it; at 795px it lost its glyphs while the page was still two columns
wide. Each of those is a card being dressed differently from its line-mates on
the same room, which reads as a fault in that card rather than as a tight page.

The icon was the wrong thing to give up, for the reason it was given up last:
it is the only part of a row that carries the grade - the glyph wears it, the
figure beside it is white whatever the reading - so a row without it states a
number with no verdict on it. It goes nowhere now, on any panel, at any width.

And the figure step was in the wrong place. Four cards each stated their own,
and the four had already come apart: the latest test stepped at 22rem of list,
the summary at 30rem along with its descriptions, the stability card at no width
at all, and the value cards were simply a size smaller everywhere. So it moves
into the shared row as an unnamed container query, which steps on whichever list
the row is drawn in. Every panel makes its list a container, so the threshold is
the same for all of them by construction, and cards drawn side by side hold the
same width and step together at every stage of the page.

Two steps rather than one, because the room the glyph used to free has to come
from somewhere: 1.75rem down to 1.4 at 22rem of list, and to 1.2 at 20rem, which
is the width the icon used to go at. Measured across fifteen languages with the
glyph kept, as the widest list that still cuts a label:

                  1.75rem   1.4rem   1.2rem
  summary           380px    360px    350px
  latest test       350px    316px    290px
  stability         270px    250px    240px
  value cards       360px    328px    300px

22rem clears the latest test's 350 and 20rem clears the 316 the first step
leaves it. Re-measured after, over 6930 rows in fifteen languages at 22 widths:
no glyph hidden anywhere, no two cards of equal list disagreeing on a size, and
76 rows cut where there were icons missing from five rows of three cards. Of
those 76, 58 are at a 320px viewport and 11 more at 360 and 375, where a phone
holds 238-293px of list and "Téléchargement" does not sit beside a glyph and a
speed at any size a figure can be stated at. The rest is "Самый высокий" short
2-5px in two 8px-wide bands, which a step of its own would cost every panel a
size across the whole band above to close.

The summary closes its own band differently: its labels are two words where
these are one, so it wraps rather than cuts, from the width its descriptions go
rather than only at the width its rows stack. The line the description just gave
up is the line the wrapped label takes.

The stability card's list becomes a container so it can take the steps at all,
and carries the same :not(.chart-modal-body *) guard the value cards do - a size
container offers an intrinsic sizing pass nothing of its contents, and the
dialog it opens into is shrink-to-fit, which is nothing but that pass. Checked
opened at 1600, 900 and 390px: full-size figures, every glyph, nothing cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The left column is 40% because of the summary: its labels and descriptions are
the page's only sentences, and a row of them needs 473px of list to sit beside
its figure. Below the width where that stops fitting the card gives the sentences
up - and from there it is holding 100px it has nothing to put in.

That was not only waste. A panel steps its figure down on its own list width,
which is the only rule that can hold at all three stages, since the cards' shares
of the row change between them. So a card 100px wider than its line-mates steps
later than they do, and the summary stood beside two cards stating a smaller
figure than its own for the whole band. Measured at 1263px: 28px against 19.2 on
exactly the room the rule says each should have.

Every alternative pays for it somewhere. Giving the summary a wider threshold of
its own fixes the three-across stage and breaks the one-column one, where all
three cards hold the same width and it would then step first. Viewport figures
per stage break the enlarged view and want re-deriving every time a stage moves.
Equal thirds at every width costs the summary its sentences outright - an equal
third of even this page is 470px against the 473 they need. Scaling the figure
smoothly instead of stepping it makes the cards differ at every width rather than
in one band.

So the width goes back instead. 1417px is where the summary's list falls to
$overview-trim, so the card gives up the room in the same movement as the thing
the room was for, rather than twice at two widths - measured, the descriptions
already go there, and this adds no second reflow of its own.

Every card, not only the three that show it: the left column runs the whole page,
the summary over the latency chart over the hourly one, so squaring the top row
alone would put a kink in a column three rows deep.

Measured across fifteen languages at ten widths, against the same page before:

  1400px   22.4/28/28   ->  28/28/28 on 398px each
  1300px   22.4/22.4/28 ->  28/28/28 on 365px each
  1263px   22.4/22.4/28 ->  28/28/28 on 352px each

Nothing cut in any language either side of the change, and the figures come out
larger rather than smaller: the two tight cards gain 36px of list each, which is
enough to hold full size down to the two-column stage instead of dropping two
steps to 19.2px. It costs the summary a second line on one to three of its five
labels between 1252 and 1340px, where its descriptions are already gone - so the
line they gave up is the line the wrapped label takes - and the page measured the
same 1246px tall at 1300px before and after.

statisticsReflow.test.js found its stages by taking the widest ceiling in the
stylesheet, which held only while the two-column stage was the widest thing on
the page. Four of its suites retargeted onto a stage that pairs nothing the
moment this was added, so they key on the constants that name them now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecked

Two values reached a column with no validator standing in front of them, and
each was let through by the same shape of gap: a check written where the value
is not.

The thresholds asked `/[^0-9.]/`, which is not "is this a number" but "is every
character a digit or a dot" - so "1.2.3", ".." and "." were all accepted. No
server code reads ping, download or upload, so the value was stored behind a 200
and handed to the client, where Number("1.2.3") is NaN and getIconBySpeed
answers neutral for a threshold it cannot read: every speed on the page goes
grey and stays grey, with nothing on screen naming the value that did it. "."
was worse, because the ping branch splits on the dot and stored the empty
string. retentionDays already anchors its own check, with a comment saying why -
this is the same rule, two hundred lines up.

The display name is the one value in an integration payload that no module
declares, so the loop holding every type and length cap never saw it. It went
onto the result unread and into displayName, a bare Sequelize.STRING: on MySQL
that is VARCHAR(255), where an over-long name was ER_DATA_TOO_LONG - a 500 and a
stack in the operator's log - while sqlite stored it whole behind a 201. The two
supported backends answered the same request differently. A non-string reached
sequelize's own validator and was a 500 on both. It now wears the cap a declared
text field wears, which is also what keeps it inside the column; undefined stays
allowed, because create() leaves the name to the column default and patch()
reads undefined as "leave it alone".

The two field caps are named while they are being read twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the app's own controls could be operated with a pointer and with
nothing else, and each was the only route to what it opened.

The date range decides what every figure on /statistics is about, and its
trigger was a div carrying an onClick - so Tab walked past it and Enter did
nothing if focus were forced onto it. The presets live inside the popover it
opens, so a keyboard-only reader was left with whatever range the page loaded
with, on both toolbars that draw one. The four month and year arrows behind it
were buttons already, and announced as nothing: each holds one FontAwesome
glyph, which renders aria-hidden, so all four had an empty accessible name and
which one stepped a year rather than a month was carried entirely by how many
chevrons could be seen.

The settings menu is the only route to nine dialogs - optimal values, the
provider, storage, the password, the schedule, pause, integrations, the language
and the preferences - and every entry was the same click-only div. The export
menu's two formats were as well, behind a trigger that was already named and
already said whether it was open: a keyboard could reach the menu, open it, and
then had nothing to press.

The trigger and the export formats become real buttons, which is the answer
wherever the control is a control - the pagination and the integration menu are
both buttons for that reason - and each takes the styling resets a button
arrives with. The settings entries take `clickable` instead, for the reason it
documents: they hold an <h3>, which a button may not contain. Every button in
the picker also states its type, since one left untyped defaults to submit.

And the delta states its direction in words. The whole of it was the glyph, in a
span marked aria-hidden - right for the glyph, wrong for the annotation around
it, because what was announced was a bare magnitude. "5%" is not a reading, and
on ping and packet loss the two directions are opposite verdicts, so the part
that says which one was the part being withheld.

Six new strings, in the source locale and in German.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither overlay managed focus at all. Opening a settings dialog from the header
gear left focus on the gear, behind the backdrop: nothing announced that a modal
had opened, Tab walked the whole page underneath before reaching the dialog, and
closing it dropped focus to the top of the document instead of back to the
control that opened it.

The close control was worse than unlabelled. It was a bare FontAwesome svg
carrying an onClick, and FontAwesome renders its svg aria-hidden - so it
announced as nothing and, being an svg, was not in the tab order either. A
reader was never told a close control existed and had to know to press Escape.

Both take one hook, for the reason `clickable` is one function: written out per
component is how the shape comes to be missing from one of them. The judgement
inside it - where Tab should land - is exported apart from the hook and tested
without a DOM, the split useClickOutside already makes. Returning null for a Tab
in the middle is the case that matters: that key is the browser's own to answer,
and claiming it would mean re-implementing tab order rather than closing it into
a loop.

The listener sits on the dialog rather than on the document, so the overlay on
top is whichever one holds focus and no isTopmostOverlay question arises - unlike
Escape, which the document has to arbitrate because it is pressed at the page
level. Only the alert on top takes focus; one stacked under another has given up
its turn, and when the one above closes it becomes top again and pulls focus
back into itself.

Focus already inside is left alone, because the input variant of an alert
autoFocuses its field and moving that to the first button would put the caret
nowhere.

An alert names itself with its own title. The Dialog cannot: its heading is
written by whoever renders DialogHeader, so the id travels between the two
halves through a context, generated per dialog rather than fixed - two of these
can be mounted at once, and a repeated id names the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing MySpeed does needs a privilege. It listens on 5216, which is above the
reserved range, and it writes its database and its logs inside its own
installation directory. What it also does is download a third-party speedtest
CLI at first boot and then spawn it - so running the whole of that as uid 0 meant
a replaced upstream asset, or any remote-code flaw in the server, executed with
full access to the host filesystem. The Docker path for the same code already
drops to an unprivileged user; the native installer did not.

The account is created before the unit names it and the installation is handed
over before the service is started. That order is the whole of the upgrade case:
on an existing install every file is owned by root because that is what put it
there, so a service that starts first meets a database it cannot open. The chown
names the user and not user:group, because useradd --system creates a matching
group on Debian and RHEL but not everywhere, and a chown naming a group that
does not exist changes nothing.

The home directory is the installation path rather than nothing, because the
Ookla CLI writes its licence acceptance under $HOME and an account with no
writable home would be asked for it again on every run.

A system with no useradd falls back to root and says so. A unit naming an
account that was never created starts nothing at all, which is a worse outcome
than the privilege this drops - and it would be discovered as "the service
stopped working after an update" rather than as anything to do with this.

The sandboxing is the standard baseline, and none of it restricts a userspace
HTTP server that spawns a CLI and writes inside its own directory:
ProtectSystem=full leaves /opt alone, and ProtectHome covers /home and /root,
which is not where this account's home is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two guards and a set of comments that had stopped being true.

The jitter unit is held against the ping unit, in every locale that carries
both. They are the same unit two lines apart, so one being mangled while the
other survives is the whole shape of the fault: latest.jitter_unit read "M" in
German, "m" in Spanish and "Mms" in French, and nothing could catch it.
germanLocale.test.js already names the key in its SHARED set - the values a
German reader sees in the same shape an English one does - but that set is only
ever used to *exempt* keys from the copied-English check, so the one assertion
built on it fires when de equals en, which is the case that is correct.

Read out of the same file rather than against a literal "ms", because ru says
"мс" and zh says "毫秒" and both are right. This matters as CI rather than as
tidiness: crowdin.yml maps every non-English file here as a translation of
en.json, so the next sync can bring all three back.

It found a fourth. ru.json states ping in Latin "ms" and jitter in Cyrillic
"мс" - the same reading twice in one card, in two scripts. Russian names
milliseconds "мс" in the three other places this file does it, so the ping unit
is the outlier, and it is now what the rest of the file says.

The MSI's third link is held too. ARPURLUPDATEINFO was written and left
unasserted, which is the state a property reaches just before someone tidies it
away as unused, and all three are now checked to point at this project rather
than only to exist.

And the comments. Four claims had gone stale, three of them in the same commit
that introduced them:

  - "no card can step at a width of its own" was contradicted by a live rule.
    ChartModal states the first step for the enlarged value card at 500px of
    viewport, which is the one figure size outside the shared row and the one
    keyed on a viewport - necessarily, because that card withholds its container
    in the dialog and there is no list left to key on. Both ends now say so.
  - "a card drawn inside a dialog takes no step at all" holds for three of the
    four panels and for three different reasons, none of them the one given. The
    summary keeps its container in the dialog on purpose, so it is the panel that
    does step there.
  - "frees comparable room" was half: the second step buys 26px where the icon
    it replaced was 52. The trade is still worth making; it is not a swap.
  - "a phone narrower than 375px" put the residual cut band a phone size too low.
    A card holds the viewport less 82px at the one-column stage, so the value
    cards' floor is a viewport of ~382 and the latest test's ~372 - the common
    375px phone is already inside the first.

Two more were invalidated by the equal-thirds stage rather than by their own
commit: the latest test's "~324px of list in the three-across grid", which no
three-across width produces any more, and layout's claim that the new stage holds
every figure at full size, which is true across all but its bottom eleven pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An adversarial pass over the five commits before this one found that three of
them had broken something while fixing something else. Each is the same shape:
a change that is right on its own terms and wrong about what it sits next to.

Enter on a confirmation had started cancelling it. The focus trap seats focus on
the first focusable in the dialog, and the close X the same commit added is the
first thing in the header - so a confirmation opened with focus on the one
control that resolves it with null. The document handler declines a key aimed at
a button inside the alert, on purpose, so the browser turned Enter into a click
on the X: "delete this test", "remove the password" and the node delete all
answered Enter by doing nothing, where before they confirmed. An alert now opens
on its primary button, which is what it should have been either way, and the
hook takes the target rather than guessing it.

Focus was also given back to the wrong element. The hook read
document.activeElement in a passive effect, which runs after React has applied
autoFocus - so an alert that opens on an input recorded its own field as the
place to return focus to, and returned it to an element that had just been
unmounted. The alert records the control it was opened from at the moment it is
asked for.

The welcome dialog was announced as an unnamed dialog. Every Dialog emitted
aria-labelledby pointing at the id DialogHeader stamps, and the welcome dialog
is the one consumer that draws a banner instead of a header - so the reference
resolved to nothing, which names it less than no role would have. It says its
name outright now, and that is also the one modal nobody can dismiss.

The delta's label was on a bare span, where ARIA prohibits naming: role=generic
cannot be named from the author, so conforming readers drop the label and fall
back to the visible text - the bare magnitude, with the arrow still hidden. The
fix would have looked present and delivered nothing. role="img" is what carries
a name on a non-interactive element.

And two in the installer, both from -d taking any path. ProtectSystem=full with
no ReadWritePaths made the one directory the service writes read-only whenever
that path was under /usr, and ProtectHome=true made it unreachable whenever it
was under /home or /root - each a permanent restart loop behind a banner saying
the install completed, which is what the AVX2 and download guards exist to
prevent. The sandbox now names $INSTALLATION_PATH, and ProtectHome is gone: a
dedicated unprivileged account already cannot read other users' homes.

The recursive chown was worse. It ran over whatever -d was given, and "-d /opt"
is one slip from "-d /opt/myspeed" - which would have handed every other
application under /opt to an unprivileged account, silently, with nothing that
reverses it. Before this the installer changed no ownership at all, so a
mistyped path cost a stray binary. It now refuses any directory that does not
hold the binary this script has just written.

The threshold regex was tightened past the defect it was for. ".5" and "1." are
0.5 and 1, the check this replaced took both, and importConfig runs every stored
key back through the validator and abandons the whole restore on the first
refusal, naming no key - so a threshold saved as ".5" on an existing instance
would have taken the nodes and the integrations down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two constants landed between validateInput's doc comment and validateInput,
so the @PARAM isPatch block documented a number and the function was left
documented as "the caps a declared text field wears" - which says nothing about
the patch contract a reader hovers it for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Focus could leave a trapped overlay and never be brought back. The Tab listener
sits on the dialog, so it hears only keys pressed inside it - which is enough
while focus stays in, and focus can leave without any key at all: a mousedown on
the backdrop blurs to the body, and so does a control that unmounts itself. On
the welcome wizard, whose backdrop click is a no-op and which has no Escape
either, that state was permanent - the trap went inert and Tab walked the page
behind a backdrop advertising aria-modal. A focusout recovery seats focus again
whenever it lands outside, and an overlay opened over this one is not counted as
an escape, because pulling focus out of a stacked alert would fight it for it.

Being open and holding focus were one state, and for an alert they are two. The
restore was keyed on the second, so a second alert stacking over the first handed
focus back to the page underneath two backdrops - scrolling the page to get
there - and the alert above then recorded that page control as its own place to
return to. They are two effects now: `open` owns the restore, `holdsFocus` owns
the seating and the trap. An alert that takes its turn back returns focus to the
control it was last on rather than to its first.

The date picker announced a pop-up dialog it does not open. Behind the trigger is
a popover with no role, no name and nothing that moves focus into it, so a reader
was told they had opened a dialog, pressed Enter, heard "expanded", and was then
told nothing. The markup is a disclosure - content immediately after its trigger,
dismissed with Escape - and aria-expanded states that correctly on its own.

And the uninstaller now removes the account the installer creates. It stopped the
service, deleted the unit and the whole directory, then reported MySpeed
uninstalled while leaving a `myspeed` entry in /etc/passwd whose home directory
was the path it had just deleted - and it survived every later uninstall, since
install.sh only runs useradd when the account is missing.

Not under --keep-data, which is why that is a condition and not a line: the flag
exists to leave the database for a later reinstall, and those files belong to
this account. Delete it and they belong to a free uid, which the next account
created on the host may be given. Data that is kept keeps its owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rule kept

Two loose ends from the focus work, both found by reviewing it rather than by
running it.

The close button added to the alert header declared its type; the three beside it
did not. A button left untyped defaults to submit, and typing one of four is the
state where the next reader cannot tell which way the file means to go. All four
now say it, and the rule is held against both overlays rather than against the
one control this range happened to add.

And the Enter guard's rationale had outlived itself. It explained the rule by
saying that "an alert is portaled to the body with no focus trap and only the
input variant autofocuses, so one opened by a click leaves focus on the page
button that opened it" - which was true when it was written and was made false by
the focus trap two commits ago. The case it describes is now unreachable, and the
guard is still right, for a reason the comment did not give: Enter on Cancel or
on the close X belongs to that control and not to handleSubmit. Which is also how
the same trap came to answer a confirmation by cancelling it, so the two are worth
reading together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seating rule took the first focusable in document order, and in both overlays
the header comes first - so the first focusable is the X. That is how an alert
came to answer Enter by cancelling a confirmation, fixed two commits ago by
naming its primary button; the Dialog has no such name to give and was left
opening on its dismiss control.

Milder there and still wrong in two ways: Enter closes a settings dialog the
moment it opens, and a keyboard reader has to Tab past the dismiss button to
reach the first field of every dialog in the app - the password, the optimal
values, the schedule, the integrations.

So the rule skips it rather than each overlay working around it. The control is
marked with an attribute rather than recognised by its class, so what the rule
means is written where the rule is and does not follow the styling around; and
the X is still taken when it is the only control there, which is what an alert
with no buttons of its own leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s it

The server's copy of this rule was anchored two hundred lines of review ago;
the client's was left asking `/[^0-9.]/`, which is not "is this a number" but
"is every character a digit or a dot" - so "1.2.3", ".." and "." all satisfied
it. That is the same defect, in the same shape, in the other half of the app.

What it cost is small and specific. The dialog patches its three fields one
after another, so a bad third value was refused only after the first two had
been written: the operator saw an error over a change that had partly happened,
and the message came from the server rather than from the field they typed it
in. The client's own "invalid" toast could not fire for exactly the values it
exists for.

The rule goes beside getIconBySpeed, which is what breaks without it - it
divides by this value, and Number("1.2.3") is NaN, so an unreadable threshold
takes every speed on the dashboard to the colour for a figure nobody measured.
isFailedTest two hundred lines above is the precedent: a judgement the server
also makes, neither side able to import the other, the two pinned to one table
by a test. This one reads the server's regex out of its source rather than
restating it, so the pair cannot drift again without failing.

And withoutComments, because the negative assertion needed it. A scan saying a
construct is *absent* is answered by prose, and the house style names what a
change replaced - so the comment beside this fix, which says what `/[^0-9.]/`
asked, is precisely what an assertion that `/[^0-9.]/` is gone finds. Left
alone, the rule holds the explanation hostage: reword the comment to keep the
test green. It is worth having as a helper rather than as a reworded sentence,
since every `doesNotMatch` over a source file in this repository has the same
exposure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every regression this hook has had lived in the one part of it nothing
executes. modalFocus.test.js covers the four exported judgements - which
control Tab should land on, whether focus has escaped - and they are the easy
half. The other half is two effects, one keyed on being open and one on holding
focus, what their cleanups do, the order React runs them in, and a recovery
scheduled from a focusout. Enter answering a confirmation by cancelling it,
focus restored to a field that had just been unmounted, an alert stacking over
another and handing focus back to the page beneath two backdrops, an overlay
opening on its own close button: four bugs, all in that seam, all of which
shipped past a green suite.

There is no jsdom here and no renderer, and neither is worth a dependency for
one hook. So the DOM is the dozen methods the hook actually calls and React is
its two, with the deps comparison and the guarantee that matters - on a
re-render every cleanup runs before any effect. The timers are real: the
recovery is scheduled with setTimeout(…, 0) and the cleanup clears it, and
waiting a turn is the only thing that tells those two apart.

The hook is loaded by rewriting its react import, so the file under test is the
file on disk and nothing about the import graph is arranged for the test. The
substitution is asserted, so a hook that stops importing those two fails here
rather than quietly testing something else.

Held to mutation testing rather than to its own green tick, because a scenario
that cannot fail proves nothing: each of the ten defects above and beside them
was reintroduced into the hook in turn and the suite had to catch it. It caught
nine. The tenth - a trap that claims a Tab in the middle of the dialog instead
of leaving it to the browser - was survived, because the assertion asked whether
this control had been focused again rather than whether focus had moved at all,
and a trap claiming that key moves it somewhere else entirely. That assertion
now asks the right question, and the tenth is caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Added two commits ago so that an assertion about absent code would not be
answered by a comment describing it. Reviewing it found that it gets the
question wrong on 13 of this repository's 548 source files, and gets it wrong
silently - the direction where a scan quietly stops seeing what it is about.

Both causes are the same missing piece: it does not track regex literals,
because telling one from a division needs the previous token, and in JSX "</"
and "/>" are neither. A pattern carrying a quote - /['"]/ - opened a string
state that no later quote closed. A pattern carrying a backtick, which
server/util/markdown.js has, opened a template literal, and those really do
span lines, so every comment after it in the file was read as string and left
standing. One ending in an escaped slash before its delimiter puts two slashes
together and reads as a comment opening.

Scoping the string state to a line fixed the first - a quoted literal cannot
contain a raw newline, so the damage stops at the end of it - and did nothing
for the other two. What is left needs a lexer, and a lexer here needs a parser,
which is a dependency this repository does not otherwise carry for one
assertion.

So the assertion goes back to reading the file whole, and the comment beside
the fix names the character class in words instead of writing it out. That is
the smaller thing to give up: one sentence phrased around a scan, against a
shared helper that would go on being reached for and would go on being wrong in
the direction nothing notices.

The other half of that commit stands and is untouched - the client's rule is
still anchored, still held against the server's own regex, and the assertion
that it is called once per field is tightened, since asking only whether the
name appears is answered by the import line while the guard below it uses
something else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard on the recursive chown could not do what its own comment claimed. It
asked whether $INSTALLATION_PATH held a `myspeed` file - but the script writes
that file itself fifty lines earlier, `cd`ing into the path and `mv`ing the
downloaded binary to that name. So on any host without a prior install the test
passed for every path it could be given: "-d /opt" re-owned every other
application under /opt to the service account, and "-d /" the filesystem. It
fired in exactly one case, where `myspeed` was a *directory* rather than a file
- which is a genuine earlier install one level down. Backwards in both
directions.

Reproduced before the fix: with a nextcloud config beside it, `-d /opt` left
/opt, /opt/nextcloud and its config.php owned by `myspeed`, and the account
could then overwrite them. The installer printed that the installation had
completed.

There is nothing to guard now. The server writes `data` and `bin` and nothing
else at the installation root - the database, the logs, the server lists, the
certs and the CLI it downloads all sit under those two - so the installer makes
them itself and hands over those, which are directories it created. A mistyped
path costs a stray binary and two empty directories again, as it did before any
of this. The binary and the root stay with root, which is worth having on its
own: the account that runs the binary cannot rewrite it.

And "-d /root/myspeed" was made permanently unstartable by the same commit that
dropped the privilege. The comment beside ProtectHome names that path as one a
root user reaches for, and the reasoning stopped at the sandbox: /root is 0700
root:root, handing over the installation never touches /root itself, and
systemd chdirs and execs *after* dropping to User= - so the unit failed chdir
with EACCES and Restart=always made a loop of it, behind the same completion
banner. Confirmed by running the two operations systemd performs, as the
account: `can't cd to /root/myspeed`, and `Permission denied` on the exec.

So the path is checked for what it actually needs, traversal by that account,
and an installation it cannot reach falls back to root and says why - the shape
the missing-useradd case already had. Two ways to end up as root, one sentence
each, rather than a service that never starts.

The three tests over this were all satisfied by source text. One asserted the
`if` and the `exit 1` were present, which they were, while the protection did
not exist; one asserted the chown named $INSTALLATION_PATH, which is the defect
itself. They now pin what has to hold: that no chown names anything but the two
directories, that the installer creates them, and that reachability decides the
account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hidden entry

The focus restore two commits back works for every overlay opened from a real
button and does nothing at all for the nine opened from this menu - which is
most of them: optimal values, the provider, storage, the password, the schedule,
pause, integrations, the language and the preferences.

The menu is never unmounted. DropdownComponent toggles `dropdown-invisible`,
which is `visibility: hidden`, and every entry is a `clickable` div that takes
focus when it is activated. So the element focus sits on when an entry is chosen
is one the same call has just hidden - and a hidden element keeps `isConnected`
true while `focus()` on it does nothing. The dialog therefore recorded, passed
the guard with, and handed focus back to an element that cannot take it, leaving
the reader on <body>: the exact outcome the restore was written to prevent, on
the commonest route to it.

Answered where the menu is rather than in the hook. A modal has nothing sensible
to do with a hidden element, and a menu owes its trigger the focus back when it
closes - so the gear takes it, and what the dialog then records is a control
that is visible and can be returned to. Only when the menu holds focus: the same
function runs for a click outside, where focus belongs to whatever was clicked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Anchoring the client's check made it refuse a value it never sends. `update`
patches a field only when it differs from what is stored, but the guard ran over
all three regardless - so an instance already holding a malformed threshold, and
every MySpeed up to 1.3.4 stored whatever the unanchored check let through,
could no longer save a change to either of the other two. Before the anchoring
that same value passed the guard and was never sent, so the save worked.

Worse than a refusal, it was an unattributable one. The field holding the
culprit reads empty on screen, because a number input drops a value it cannot
parse - so the toast named nothing and the field it was about looked blank.

The guard now asks the same question the send does. A value that is being sent
and is not a number is still refused, which is the whole point of the change;
one that is only sitting there is left alone, exactly as the server leaves it
until something writes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y that matters now

The comment justified --home-dir with the Ookla CLI re-prompting for its licence
acceptance without a writable $HOME. It never could: server/util/speedtest.js
spawns it with --accept-license and --accept-gdpr on every run, so there is no
acceptance to store.

Worth correcting rather than leaving, because the commit before this one stops
handing the installation root to the account - so $HOME is now genuinely
unwritable by the account that names it, and a reader going by the old comment
would conclude that speedtests are about to start prompting.

What the coupling actually costs is named instead: useradd only runs when the
account is missing, so a reinstall at a different -d path leaves the home
pointing at the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness ran the hook and could not see four of the things it was written to
check. Each was proved by reintroducing a defect and watching the suite stay
green.

Every listener got its own copy of the event, so preventDefault set a flag on
something discarded the moment the listener returned. A trap that claimed *every*
Tab - which makes the key dead inside every dialog in the app, so a reader can
never leave the control it opened on - was indistinguishable from one that
claimed none, because both leave focus where it was. One event now propagates,
as a browser dispatches it, press() hands it back, and the two cases are asserted
apart: claimed where the trap wraps, left alone in the middle.

Every render built a fresh `{current: dialog}`, so the trap effect's dependency
list compared unequal every time and re-ran whatever else had changed. Both real
callers hold a useRef, so in the app `holdsFocus` is what re-runs it - and with
it dropped from the dependencies the whole stacked-alert suite still passed,
including the case that exists for it. The ref is held across renders now, which
is what the callers do.

Nothing exercised the cleanup's detach. An alert stacked over gives the trap up
and takes it back, and without the detach it keeps the old listeners and adds a
second set - so a later focusout schedules two recoveries while the effect tracks
one, and the other fires into a dialog that has closed. The listeners are counted
after a turn is given up and taken back.

And a closed overlay was never actually closed: unmounting ran the hook's
cleanups while leaving the nodes in the tree, and removing a focused element left
document.activeElement pointing at it - a state no browser produces, and the one
that hid the recovery's `isConnected` re-check. remove() now moves focus to the
body as a browser does, and the recovery is held to it.

The other half of that re-check turns out to decide nothing: seat() goes through
initialFocusTarget, which answers null for focus already inside, so
`!dialog.contains(...)` is an early-out rather than a guard and no test can tell
it from its absence. Said where it is, so it is not later read as a gap.

Fifteen defects reintroduced one at a time, fourteen caught, the fifteenth being
that equivalence.

Also: the patched module went to a guessable path under a shared /tmp that
writeFileSync would follow a symlink to, and was never removed - this machine had
eighty-two of them. It goes in an mkdtemp directory, removed when the run ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of these change behaviour; each is a sentence a reader would act on.

The display name's history overstated the bug in one direction and understated
the fix in the other. The create route answers 200, not the 201 the comment and
the test both named. And a non-string did not simply fail: sequelize's STRING
validator - checked against the version in the lockfile - refuses a boolean, an
object and an array, and lets a *number* through, so `42` was stored as the text
"42" with nothing said. Which makes the type half of that check the only thing
standing between a numeric display name and a silently coerced one, rather than
belt-and-braces beside the length cap it sits with.

The date picker's trigger names two of the styles a button arrives with that its
rule does not state. There are four: font-size and font-weight are unstated too,
and as a div the trigger computed 16px/600 against a button's 13.3px/400.
Nothing shows it, because both children state their own size and every box
measurement there is in rem - so the comment now says which four and why the two
it does not set are safe, because the next thing put in that trigger is what
would find out. A bare text node, or a padding in em, takes the 13.3px.

An assertion in the overview's modal styles failed with "a label wraps while its
icon still shows". The icon always shows now - that is this branch's central
change, asserted three lines above and again across all four panels - so the
message named the intended state as the fault, and would send whoever tripped it
looking for a `display: none` rule that was deliberately deleted. The comment
beside it had been updated; the message had not.

And the calendar arrow parity check was one rename away from testing nothing.
It sliced between two class names, and a missing first marker makes indexOf
answer -1, which slice reads as one character from the end - an empty window, a
parity of nothing against nothing, and a fifth unnamed arrow invisible. Both
markers are checked before the slice, and the window has to hold the four arrows
that are there. Renaming the container now fails it, where it passed before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are the same shape: a cap added on this branch is right, and the data
already on an instance predates it.

A backup carrying an unreadable threshold could not be restored at all. ping,
download and upload were guarded by a negated character class until this branch
anchored it, so "1.2.3", ".." and a lone "." were all stored behind a 200 by
`PATCH /api/config/:key`. importConfig runs every stored key back through the
validator and abandons the whole import on the first refusal, naming none - so
the nodes, the integrations and the recorded history were all lost to a display
preference no server code even reads, and the operator got "Error importing
config" with nothing to act on.

Those three now fall back to the default and the restore completes. It is the
trade the rule already makes for ".5" and "1." - a value that was legal when it
was saved must not take a restore down with it - except that these cannot be
kept as they are, because Number() cannot read them and a threshold it cannot
read greys every speed on the dashboard. Only these three: anything else refused
there is a value the server acts on, and guessing at one would restore an
instance that is not the one that was backed up. The three keys are named once
now rather than spelled out at each of the two places that treat them apart.

And an over-long display name locked its integration out of editing. It is the
one value on that form no module declares, so it fell outside the client's field
validation as well as the server's - and the card resends it on every save
whether or not it was touched. An integration created against sqlite before the
cap existed could hold a longer name, and every later save of it was a 400 whose
only sign was the card's generic error state: no field marked, nothing naming
the length, and the offending value one the operator never edited. It wears the
same 250 a declared text field wears, on both sides, and the field is marked
like every other. The same dead end met anyone typing a 251st character.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`chmod +x` is masked by the file mode creation mask - POSIX says so - and wget
creates its output at 666 less that mask, so on a host where root runs with 077
the installed binary came out 700.

That was harmless while the installation was handed over whole: the service
account owned the binary, so 700 still let it run. It does not own the binary
any more, by design - root keeping it is what stops the account rewriting the
thing it executes - and 700 root:root is a binary the service can neither read
nor execute, under a unit with Restart=always.

The install directory is created under the same mask, so a fresh install on such
a host is caught by the reachability check and runs as root anyway. The way in
is an upgrade: the directory already exists from an install made under a laxer
mask, so it is traversable, nothing falls back, and only the new binary comes out
unreadable.

755 outright. It is the mode this file has always ended up with in practice, and
the one a binary owned by root and run by another account has to have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every button the keyboard work touched states its type, and the rule is held
against the pagination, the date picker, the export menu and the settings menu.
The three dialogs those overlays open were never in it: two buttons in the
optimal values dialog, two in the integration card and one in the welcome
wizard were left untyped, so a rule the branch introduced applied to four
components out of seven.

Nothing renders a form in this client, so an untyped button submits nothing
today. It is worth closing while it is five attributes rather than meeting the
exception on the day a form appears.

The rule itself asked for `type="button"` immediately after `<button`, so it was
really a rule about attribute order - a `ref` or a `key` written first failed a
component that had lost nothing, which is exactly what happened once already
when the alert's submit button gained a ref. It now looks anywhere in the tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g it

Three menus, one shape. Each renders its contents only while open, so closing
unmounts whatever has focus - and the reader is left on <body>, with the next
Tab restarting at the top of the document.

All of it is reachable only because of this branch. The export formats and the
date picker's trigger and presets were click-only divs before it: a pointer has
no focus to lose, so making them answer a keyboard is what created the state
where focus can be dropped.

The export menu drops it when a format is chosen - setIsOpen(false) takes away
the button the choice was made on. The date picker drops it three ways: Escape
and the trigger toggle both run closePicker, a preset closes the popover from
inside itself, and so does the second click of a day range. Escape is the one
that matters most, since returning focus to the trigger is the whole of what a
disclosure owes when it is dismissed.

Both give it back to their own trigger, which each already held in a ref for its
click-outside. Guarded on the popover actually holding focus, because closing is
not always the menu's doing: a click outside closes these too, and there focus
belongs to whatever was clicked - and the date picker's trigger closes itself,
where focus is already in the right place.

This is the third menu of this shape; the settings menu was the first, and it
needed a different answer because it hides its entries rather than unmounting
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The focus trap added on this branch shut the integration dialog's create menu
out of the dialog it belongs to.

DropdownSelect portals its menu to the body, and has to: the dialog it opens
inside carries a backdrop-filter, which makes that dialog a containing block for
anything positioned fixed within it, so a menu rendered in place would be
positioned against the dialog rather than the viewport. The menu is therefore a
sibling of the backdrop, not a descendant of the dialog - and every question the
trap asks it is a question about containment.

So the trap answered "outside" for a control the reader had just deliberately
opened. Tab wrapped within the dialog and never reached the options, and focus
that did land on one was read as an escape and recovered back out. That menu is
the only way to add an integration at all, so a keyboard could open it and then
had nothing to press - which is the export menu's own bug, arriving from the
other direction, in the same release that fixed it.

Two halves. The popover says it belongs to an overlay, and focusEscaped reads it
as part of the one that owns it rather than as the page behind. And the menu
places focus on its first option when it opens, which is how a menu behaves
anyway and is the only way in, since no Tab the trap allows can get there.

Choosing an item also gives focus back to the button, which Escape has always
done and selection never did: the option it was chosen on is unmounted by the
same call, so focus went to the document.

Checked for others - the alert and the dialog portals are themselves backdrops
and were already covered, and the two on the home page sit under one.

handlerIn took the parameter name of the handler it lifts out for granted, so it
could only run ones that happen to call it `e`. It reads the real one now, which
is what lets the select handler be run rather than pattern-matched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A restore is abandoned whole on the first value the validator turns down - the
nodes, the integrations and the recorded history with it - and the answer was
"Error importing config" and nothing else. The operator is holding a file that
will not go back and sixteen stored values to bisect by hand, with no hint that
the problem is one of them rather than the file, the database or the version.

This branch made that more reachable before it made it less: anchoring the
threshold check added a way for an old backup to be refused, and the fallback
added for those three keys does not cover the other thirteen. A cron expression
an older version accepted, or a hand-edited password that is not a bcrypt hash,
still stops everything.

So the refusal carries the key. importConfig answers with a shape rather than a
boolean, because the useful half of a refusal is which value it was; the two
paths with nothing to blame - a payload that is not a backup, and a write the
database turned down - keep the plain wording, since naming a key there would be
inventing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing in the test workflow built it. The suite reads the client's modules as
text, because node cannot parse JSX, and lint parses each file on its own - so
neither resolves an import. A component importing a sibling whose filename
differs only in case builds on a case-insensitive filesystem and fails on CI,
and the failure names the generated service worker rather than the import that
caused it. This repository has met that one.

The only workflow that compiled the client was build-binaries, which runs on
`release`. So the first build of the thing every user installs happened after
the release was published, and a build-only failure was something the person
installing it found first.

After the suite rather than before it, because the tests are the cheaper signal
and the one that says more about what broke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things this branch's own focus work got wrong, and a doc comment
that ended up over the wrong symbol.

The export menu focused its trigger and disabled it in the same commit.
`disabled={exporting}` is set by the very call that closes the menu, and
a disabled element is not a focusable area - the browser's focus fixup
rule takes focus off it and puts it on the viewport. Focusing and
disabling together is exactly as good as never focusing at all, so the
reader still ended on <body>: the bug the fix was written for. The debt
is recorded when the menu closes and paid when the export ends, which is
the first moment the button can hold focus again.

The create menu seated focus from the effect that positions it, which
also depends on `items` - and that array is built inline by the dialog
above, so it is a new one on every render of that dialog. Any of them
while the menu is open dragged focus back to the first option. Seating
is its own effect now, keyed on `isOpen` alone.

And Tab off the last option walked out of the modal. The menu is
portalled to the body, so the dialog's trap - a keydown listener on the
dialog - never hears a key pressed in it: Tab went past the end of the
menu to the page underneath, the blur closed the menu, and the reader
was left on a control behind a backdrop still announcing aria-modal. Tab
cycles the options now, using the trap's own nextFocus so a step in the
middle is still the browser's to answer. Escape remains the way out.

importConfig's own documentation had REFUSED inserted between it and the
comment, leaving each described by the wrong block.

handlerIn takes the last bracket before the arrow rather than the first,
so a handler wrapped in useCallback lifts out the same way a bare one
does. Every one of the ten mutations these tests are written against
fails at least one of them.
Measured in Chrome 148 rather than assumed: removing a focused element
fires no event at all, and neither does disabling one. Focus becomes
<body> in silence, and re-enabling does not bring it back. So the modal
trap's recovery - a focusout listener - cannot see either case, and the
component doing the removing is the only place that knows.

Adding the *first* integration is that case. The dialog's empty state and
its list branch hold two different DropdownSelects, so the trigger the
menu has just handed focus to is removed in the same commit that draws
its replacement. Every later add keeps the same menu and was already
fine. The dialog now records the debt when the branch is about to flip
and puts focus on the create button in its new place, so the first add
ends where every later one does - which is the one person this menu
exists for, adding their first integration without a mouse.

The menu no longer claims Tab when it holds no options: nextFocus answers
for an empty container by handing back the container, which is what a
dialog wants and not what this menu wants - it has no tabIndex, so the
key would be swallowed against an element that cannot take focus and Tab
would do nothing at all.

And useModalFocus no longer names an unmounting control among the cases
its recovery answers. Focus does go to the body there; nothing tells this
hook about it.

Six more mutations, each caught.
Both forward arrows carry disabled={isCurrentMonthView()}, and pressing
one is how that view is reached: stepping forward from the month before
the current one turns off the very control that was just activated.
Chrome fires nothing when a focused element is disabled, so nothing
answers it - the reader is dropped out of an open popover onto <body>,
with the next Tab at the top of the document, and re-enabling the arrow
later does not bring focus back.

Reachable because this branch made the popover openable without a
pointer at all; before it, a keyboard never got in here to lose anything.

Focus goes to the step back: at the boundary it is the only direction
left, and it is the one arrow that is never disabled. Guarded on <body>,
which is exactly the state the browser's focus fixup leaves behind -
Safari does not focus a clicked button, so there the step never held
focus and there is nothing to give back.

Six mutations, each caught.
The trap could only ever hear focusout, and neither of the two ways a
dialog loses focus fires one. Chrome dispatches nothing when the focused
element is removed and nothing when it is disabled, moving focus to the
body in silence both times - and re-enabling the control does not bring
it back.

That is the common case, not an edge: the retention save, the schedule,
the pause window and the create-node dialog all disable their primary
button while it saves, so the usual thing to do inside a dialog is also
what empties it of focus, behind a backdrop still announcing aria-modal
and with the next Tab at the top of the document.

So the dialog watches itself. Only a mutation says anything, and the
watcher acts only when focus has landed on the body - focus that moved
somewhere real, a stacked alert or the portalled create menu, is not
something to take back, and focusout already answers for that.

seat() no longer offers the seat back to a control that has since been
disabled: it is still connected, cannot take focus, and focus() on it is
a silent no-op that left the reader exactly where the recovery found
them.

The harness grew what the case needs: a MutationObserver that honours
childList, attributes, attributeFilter and subtree - ignoring them would
pass a watcher that watches neither of the two things it is for - and a
focus() that refuses, as a browser does, to land on an element that is
disconnected or disabled.

IntegrationDialog's own restore became a layout effect, so it settles
before the watcher's microtask and a reader hears one control named
rather than two.

Nine mutations; eight caught and the ninth documented where it stands.
The create menu's key handler sits on its container, which is mounted
whether or not the menu is - so with focus on the trigger it claimed
Escape and closed a menu that was already closed. Dialog's own Escape
declines a key whose default has been prevented, which is what stops a
stacked alert taking the dialog under it down as well, so what this
swallowed was the one key that dismisses the dialog: on the create
button, inside the only dialog that draws one, Escape did nothing.

Not new here, but this branch is what makes it easy to reach - the trap
seats focus inside the dialog and keeps it there, so that button is part
of the ordinary cycle rather than a long Tab away.

The export menu answers Escape now, which is what the settings menu, the
context menu, the date picker and the create menu all already do and the
only one of the five that had nothing. A reader who opened it and wanted
neither format could Tab past both or reach for the pointer. Reachable
because this branch made the two formats answer a keyboard at all.

Both handlers are now asserted to be wired to something, which neither
was: removing the onKeyDown left every key test passing.
Three of the assertions covering the privilege drop were satisfied by a
command being mentioned rather than used, and one function was not
covered at all.

reachable_by_service is what decides whether the service account can get
to the installation - "-d /root/myspeed" is the case, where /root is
0700, the chown never touches it, and systemd fails chdir under
Restart=always. Every assertion written against the script's text still
passed with its permission check replaced by `:`, which is the whole
function saying yes to everything. It is executed now, with `find`
answered from a list rather than from the filesystem: the mode bits do
not exist on the machine the suite runs on, and what is being tested is
the walk up the tree.

Bounded, because what it runs is a `while :` loop. A walk that stops
climbing does not fail the suite, it hangs it - which is the one failure
nobody reads, and it is what happened while checking this.

useradd and userdel were both asserted as words, and `command -v useradd`
and `command -v userdel` carry those words in the guards beside them - so
an installer that only ever asked whether the host had useradd, and an
uninstaller that never removed the account, both passed.

Nine mutations across the two scripts, each caught.
The reason a step is spent on the figure rather than on the label is
that the figure is what the eye lands on, and $panel-figure-floor says
so - "1.2rem is still larger than the label beside it, so the figure is
what the eye lands on at every width the card is ever drawn at". Nothing
checked it: either step could be taken below $panel-title-size and the
suite stayed green.

The two sizes themselves stay a judgement and are left as one. Pinning
them to their exact rem would be a change detector, and the relationships
that matter - the tighter step stated second, the wider step taken before
a label is cut - are already held.
A Dialog holds focus for as long as it is open - it has no idea an alert
has stacked over it - so both watchers are live together, and a mutation
behind the alert can arrive while focus is momentarily on the body. The
watcher is a microtask and the focusout recovery is a timeout, so the
dialog underneath does win the first move.

It does not keep it: the alert's own recovery finds focus outside itself
and takes it back within the turn. Reasoned through and then run, because
the alternative was a comment claiming a race resolves without anything
demonstrating that it does. Both halves are mutation-checked - removing
the focusout recovery, or the watcher's guard on where focus actually is,
each leave the reader behind the alert's backdrop.
Comment thread server/controller/config.js Fixed
CodeQL is right, and the regex is one this branch introduced. Anchoring
the threshold check replaced a linear negated class with
`[0-9]+\.?[0-9]*`, which puts two digit runs on either side of something
optional: a run of digits that fails at the end can be divided between
them in as many ways as it is long, and the engine tries every one.
Measured here at 1.4ms for 2,000 characters, 7.2 for 5,000, 27.6 for
10,000 and 113.6 for 20,000 - doubling the input quadruples the work.

Reachable with nothing but a request, and worse through the import than
through the PATCH it was written for: importConfig runs every stored key
back through this validator and its body is parsed at a 50mb limit, so
one restore carrying a long enough threshold blocks the event loop for
as long as the caller cares to make it. A default install has no
password to stop anyone sending it.

The dot moves inside the optional group, which leaves the two runs
unable to trade characters. The accepted language is unchanged - the
table both copies are pinned to is what says so, and all 29 values in it
still answer the same on both sides.

The client's copy goes with it. Only the server's is reachable with an
input worth the cost, but the two are one rule and drift is what that
file's history is about.

Timed rather than reasoned about, on both copies, with four orders of
magnitude of margin. Reverting either regex fails its test.
…n it

Found sweeping for anything of the same shape as the ReDoS CodeQL
caught. `field.regex` was compiled and run against the raw request
value, and the only bound on that value is app.js's 100kb body parser -
so every pattern a module declares was handed up to 100,000 characters,
while the 250/2000 caps that exist for exactly that value ran afterwards.

Nothing is wrong today: all eleven shipped patterns were parsed out and
timed to 80,000 characters and every one is flat, worst case 0.3ms. This
is so the next module to declare a pattern cannot be handed more than its
column takes, rather than that being left to whoever writes it - which is
the assumption the threshold check just broke.

Every branch in that block answers `false`, so the move changes no
answer, only which check reaches it first. It stays ahead of the number
branch, which ends by writing the coerced value back: past that it would
be testing a number rather than what arrived.

The test is an ordering test and says so. A functional one cannot see
this, because both orders refuse the same values with the same answer.
The fix stands; the reach I wrote beside it does not. Both the comment
in the controller and the one in the test said a default install has no
password to stop anyone sending it. That has not been true since the
public-exposure work: handleUnconfigured admits only a loopback caller,
ALLOW_NO_PASSWORD, or the per-boot setup token, and previewReadOnly
refuses the method on a demo. So the two write routes are behind the
password, and the bug was a post-authentication stall rather than an
anonymous one.

Stated as what it is instead, and why it was still worth fixing:
restoring a backup somebody handed you is the ordinary way to hold a
value nobody typed, both routes are reachable from a session as well as
a header, and a single-threaded server stalled by one request is stalled
for every caller.

Caught by an audit that checked the premise I had given it rather than
taking it on trust.
@i7Gamer
i7Gamer merged commit 3b4d99b into development Aug 19, 2026
6 of 7 checks passed
@i7Gamer
i7Gamer deleted the fix/panel-row-steps branch August 25, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants