Instance-specific findings (S2245 singles, S4790, S5332, S2819, S7767,
S4158 sessionManager, S8786, S6825) are resolved server-side as false
positive / accepted / reviewed-safe instead, so future violations of the
same rule in the same file still surface. Narrow the i18n secrets scope
to the locale directories so top-level code files stay fully scanned,
and drop the comment claims the code contradicts.
iOS paints its own background inside a text control, and under color-scheme:
dark that fill is darker than the card the field sits on. The mobile fields
are deliberately bg-transparent because the row supplies the surface - which
is why the native fill has nothing covering it and shows through as a dark
rectangle, while a desktop field hides it behind its own background.
Closes#2028
Admin-Storage pointed at the README's Storage section for the seed file
example and the compose mount line. That section is gone, so the link went
nowhere and the example existed in no copy at all - the wiki page described
storage-config.json without ever showing one. Both are on the page now.
The commented mount in docker-compose.yml pointed at the same removed
section.
Three headings sat directly on the paragraph above them while every other one
is preceded by a <br />. Sub-headings keep following their prose directly, as
they did before.
Three more sections that only restated what is maintained elsewhere, and had
started to drift: the embedded compose example still pinned tmpfs at 64m where
docker-compose.yml has said 128m for a while, and it was missing the proxy,
language and kitinerary options the real file documents inline.
The headings stay, so the in-page nav link to #docker-compose-production and
any external link into these anchors still land.
The scan that replaced the parenthetical-stripping regex went into the Leaflet
overlay and its Mapbox GL counterpart as two byte-identical copies, which is
what the duplication gate caught. trackColors.ts next door exists for exactly
this reason - both renderers read a track's colour through one function so it
cannot come out different on one map than the other - so the cleaner follows it.
Behaviour is unchanged. The test brute-forces the new function against the regex
it replaced over all 55,987 names up to six characters of "ab() \t", and pins the
32k-space input that used to take a second.
The move to SonarCloud brought a wider rule set with it, and 23 more regexes
that can be made to hang on a crafted near-match. 21 are fixed here, each proven
equivalent to the pattern it replaces by brute force rather than by reading.
Two of them are reachable across users, not just by yourself. A reservation
endpoint name is free text any trip member types, stored and then read back in
every other member's browser while the map draws its labels, and the pattern
over it takes 2.3 seconds on a 32k paste - so one member could freeze the map
for the whole trip. The mobile transport sheet reads the same field. Both now
scan instead of backtrack, and both were matched against the desktop twin the
earlier sweep had already fixed rather than given a third spelling.
The markdown link stripper needed the same treatment as the tag stripper before
it: the label class cannot be disambiguated in the pattern, because it matches
an opening bracket as well, and narrowing it changes what "[a[b](c)" produces.
It is a scan now, with all four of the regex's quirks intact.
Also here: the two encoding warnings the scanner reported. A decorative comment
line in JourneyDetailPage.test.tsx had picked up a replacement character
somewhere along the way, and calendar.service.test.ts asserts on one
deliberately - that one is now written as a U+FFFD escape, so the file no longer
carries the raw bytes while the test says exactly what it said before.
Seven findings were deliberately not changed and are flagged in SonarCloud with
the reasoning. Five are false positives - patterns with no overlapping
quantifier pair, where the only cure would change how nested brackets parse.
Two are marked accepted rather than dismissed, because they are real: the readme
check's heading pattern has a proven linear replacement that cannot land until
the registry's copy of the same script can move with it, and
`export let JWT_SECRET` is what makes an admin's key rotation take effect
in-process instead of silently waiting for a restart.
The coverage ratchet caught something worth keeping: an inline
`(a, b) => (a < b ? -1 : a > b ? 1 : 0)` is a new function with four branches at
every call site, and two of the three in src/nest/common sit on error paths that
a passing test never walks. Three restatements of the default sort order were
enough to pull the domain under its pinned thresholds (functions 97.8% against
99, branches 87.92% against 89).
Writing tests to walk those error paths would have been solving the wrong
problem. byCodeUnit says the same thing once, is covered once, and the callers
read better for it.
Its test asserts equality with a bare `.sort()` rather than hand-written
expectations, since "identical to the default order" is the whole contract. It
also pins what byCodeUnit is NOT: `['Zelt', 'Übernachtung']` keeps that order,
where localeCompare swaps it — so the next person reaching for a comparator on a
list a human reads picks the other one.
Two passes over the ReDoS findings arrived at the same scanner independently —
one replacing `/<[^>]+>/g` in the KML importer and the Commons metadata helper,
the other in the mail/HTML text extractor. The algorithm is the same down to the
edge cases, so the extractor now imports common/stripHtmlTags rather than
carrying its own.
Checked before folding them together rather than assumed: the two
implementations were compared over 2,441,406 generated strings and agree on
every one. The shared helper takes the replacement string as a parameter, which
is the only thing that differed — the extractor substitutes a space, the KML
importer nothing.
Two cases the shared helper's own test covers only in the abstract get a
concrete one here, since this is where the input is a file somebody uploaded: an
empty `<>` stays literal, and a `<` inside a tag is content, so
`3 < 4 and <b>5</b>` strips to `3 5` rather than keeping the first bracket. Both
are the old regex's behaviour, which is the point.
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than
by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates,
currency and country codes, setting keys, route names, OAuth scopes, zip entry
names — where that order is already the intended one. Each says so explicitly
now, with a comparator that reproduces it exactly rather than a localeCompare
that would quietly reorder things.
That mattered more than it sounds. Several of these feed something other than a
display: useAtlas joins the sorted codes into the signature that decides whether
to rebuild the region layer, daysSlice re-pins sorted dates positionally onto
reordered days, and three timeline views pick a day's colour by indexOf into the
sorted array. Switching any of them to a locale collator would have been a silent
regression.
Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal
pass the index as a second argument, which placeBtn declares and uses for its
React key — correct already, now written out as .map((p, i) => placeBtn(p, i)).
And budgetSlice's optimistic paid-toggle stored the raw boolean behind
`paid as unknown as number`, while the schema declares z.number() and both the
REST response and the WebSocket event carry 0/1. So the optimistic state and the
confirmed state disagreed on type until a refresh. It normalises now, and the two
tests that asserted `true` assert the contract.
Four `| 0` truncations are listed in sonar-project.properties instead of changed.
Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two
look like plain truncation of a row id, but `| 0` is also coercing undefined to
0 there — the settlement flows the API returns carry no user_id, so that is the
normal path, and Math.trunc returns NaN and crashes the lookup.
Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.
A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
With a day selected, the 'planned' filter now shows only that day's stops,
matching how the other filters follow the selection; with no day selected
it keeps showing the whole plan. 'unplanned' still uses the whole-trip
assigned set. Discussed in #2024.
Two separate reasons v4.0.0 (#1719) is red.
The SonarQube workflow killed all 616 client test files with "Cannot find module
'@testing-library/dom'". That package is a peer dependency of
@testing-library/react and not a direct dependency of the client, and the step
that installs @swc/core's Linux binary passes --legacy-peer-deps, which tells npm
not to put peer dependencies back when it re-resolves the tree. In test.yml that
step lives in the server job, which never runs client tests, so folding the jobs
into one (#2039) is what brought the two together. It now installs alongside the
swc binary, with its version read from the peer range rather than pinned, and a
one-line resolve check right after fails fast with the module name instead of six
hundred identical failures twenty minutes later.
MAdminStoragePanel's FE-MOB-MSTOR-015 was a genuine race in the test, not in the
panel. Its POST handler incremented postCount, then awaited request.json(), and
only then pushed to migrationPosts — so between the two there was a window where
one POST had been counted but migrationPosts was still empty. The GET handler
reports `migrations: []` for an empty migrationPosts, i.e. "nothing running", so
a poll landing in that window told the queue it was free to start the second
migration, and the waitFor(postCount === 1) above could resolve inside it too.
Parsing before counting closes it. Verified by running the whole mobile suite at
--maxWorkers=8, where it used to surface.
Review feedback on #2038: the root already has a script that does all of this,
so the five jobs and the artifact shuffling between them were doing by hand what
`npm run test:cov` does on its own. It ends with `--prefix plugin-sdk`, so all
four packages are covered, and each writes its lcov to <pkg>/coverage/ — exactly
where sonar.javascript.lcov.reportPaths looks. 131 lines become 28.
Three things the plain root sequence still needs on a runner, kept as their own
steps so the reason stays visible:
- plugin-sdk is not a root workspace (workspaces = client, server, shared), so
the root npm ci never reaches it and it needs its own.
- The lockfile was generated on Windows and omits @swc/core's Linux optional
binary, so the server's SWC transform cannot load. test.yml carries the same
step for the same reason.
- Only shared is built, not the root `build`. server and client run their suites
from source through vitest, so building them adds a tsc pass and a full vite
bundle that nothing here reads.
The cost is wall-clock. The first run of the parallel version took 16 minutes,
with client (11) and server (9) overlapping; sequentially that is closer to 25.
Nobody waits on this job, so the simpler file is the better trade — but it is a
trade, not a free win.
The scan has been a manual step on somebody's machine, which is why the
dashboard could sit on a stale commit without anyone noticing. This runs it on
every push to dev.
Push to dev only, and that is the point: the instance is Community Edition, so
it has no branch or pull-request analysis and the project holds exactly one
view. Analysing a feature branch would overwrite that view with the branch's
numbers until somebody scanned dev again, so the one branch that may publish is
the one everybody reads.
The four coverage jobs mirror the ones in test.yml because Sonar needs the lcov
each package emits — without it every line reports as uncovered and the
dashboard shows a collapse that did not happen. They run in parallel and hand
their reports to the scan job, which needs a full clone: Sonar reads git blame
to date each issue and to decide what counts as new code.
sonar.projectVersion stays pinned here rather than read from package.json.
package.json is still on 3.4.1 while the project has been analysed as 4.0.0 all
along, and since New Code is "previous version", taking the lower number would
silently re-base every new-code metric.
Needs two repository secrets before it can run: SONAR_TOKEN and SONAR_HOST_URL.
The last pass reviewed diffs, so it never saw a new paragraph contradicting an
older one on the same page, or a long-standing claim nobody had checked.
Plugin authors were told ctx.config carries their per-user secrets and that
definePlugin takes a permissions key — neither is true, and both fail silently.
FORCE_HTTPS was said to need TRUST_PROXY, which it does not.
* fix(security): mint idempotency keys properly, and resolve the extractor by path
Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.
The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.
The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.
* fix: four tests and sorts that were not doing what they claimed
Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.
budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.
admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.
The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.
Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).
* refactor: give every button a type, and prefer the Number statics
Two mechanical sweeps, no behaviour change intended in either.
A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".
The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
* docs(wiki): correct the install and operations pages for 4.0.0
Quick Start passed docker an empty ENCRYPTION_KEY: an assignment prefix is not
expanded on the same command line, so the server silently generated its own key
instead of taking the one the page told you to set. The tag examples pinned to
the 3.x line, the documented tmpfs size did not match the shipped compose file,
and several .env values were described as reaching the container when compose
never interpolates them.
* docs(wiki): match the MCP reference to the registered surface
The scope table was missing the whole Collections group and the totals said 27
scopes across 13 groups where the code registers 29 across 14. The Cloudflare
link on the setup page pointed at an anchor on its own page instead of the
heading on Troubleshooting.
* docs(wiki): correct the plugin and admin pages
Three links to Plugin-Development carried a .md suffix, which does not resolve on
the wiki. The admin pages described tabs, permissions and storage behaviour that
had moved on.
* docs(wiki): bring the feature pages back in line with the app
Costs still documented the old panel, where categories could be added, renamed,
reordered and deleted; they are fixed now and the panel is a day-grouped ledger
with settlements. Trip sharing, journeys, maps, packing, reservations and the
account pages had all drifted in the same way.
* docs(wiki): cover the pages the first pass did not reach
The MCP catalog listed 168 of the 201 registered tools; the settlement tools and
rate_place were missing here and the Collections family from the addon page.
Several resource rows claimed a trips:* scope where the handler asks for
trips:read.
* docs(wiki): tighten the corrections a review picked apart
The audit-log resource is the row id, not the credential id; an admin password
reset does not bump password_version the way the page implied; and a few of the
new paragraphs described behaviour more confidently than the code supports.
* docs(wiki): fix what a last read-through caught
Costs shows every figure in your display currency, not the trip's, and an
expense with no payer still counts against the members it is split between.
Studio's cutoff is a 1024px viewport rather than phones alone, and the
Integrations tab is gated on the providers an admin has configured.
* docs(wiki): the last few the review caught
A passkey satisfies the MFA policy everywhere except the WebSocket handshake,
which still asks for TOTP, so a passkey-only account under that policy gets no
live updates. Mobile optimizes a day from two places, not three. The ctx.db row
carried an unescaped pipe that split the table, and the Backups table linked
with a wiki link whose pipe did the same.
All three sit in code whose whole job is to survive a plugin that misbehaves.
readTarGz trusted the tar size field, which parseInt reads with a leading minus.
At -512 the cursor advance cancels out and below it goes backwards, so the same
header is read forever while every pass appends a member — a 70-byte upload pins
the main thread and exhausts the heap, synchronously, taking HTTP, the socket
and the database with it. The entry budgets never get a say: they run on the
list readTarGz has to finish building first. Sideload is the reachable path.
The supervisor handed onMessage's promise to an EventEmitter listener, which
discards it. A rejection there is an unhandledRejection, the host has no net for
it, and Node ends the process — the supervisor killed by the plugin it contains.
dispatch() read `'_inv' in req.params` before handle()'s try/catch, so params
that are not an object threw a TypeError into exactly that discarded promise.
`POST /api/admin/plugins/:id/uninstall` walks its route parameter into
pluginCodeDir() and hands the result to a recursive remove. Express decodes
`%2F` after routing, so `..%2F..%2Fuploads` matches the route and reaches the
helper as `../../uploads` — path.join then lands in the uploads tree, and
`deleteData` gets the data root the same way. Verified against the express
version in the lockfile.
pluginCodeDir and pluginDataDir now reject an id that could leave its parent.
Narrower than the manifest's ID_RE on purpose: that rule wants 3-40 lowercase
characters and belongs at install time, while this one only has to make the id
unable to steer a path, so it stays permissive about length and case. Every
other filesystem sink in the backend already has its guard — storage keys,
extraction, the wiki, backup filenames. This one did not.
Both functions had two exits handing back the identical value, which reads as a
decision and is none — Sonar's S3516, and the last two Blockers on the backend.
colorizeImportedTracks returned the very object it was given, coloured in place.
Saying `void` and letting the two importers return their own result is what the
function was doing all along. PLACES-SVC-005 checked the pass-through and now
checks the thing worth checking: an empty import writes no colours.
acceptInvite bailed out early on a refusal only to return the same result the
line below returns. The notification is what the early exit was really skipping,
so it is the notification that gets the condition.
SMTP_SKIP_TLS_VERIFY (and the matching admin setting) turns off certificate
checking for outgoing mail. That escape hatch stays — an install behind an
internal relay with a self-signed certificate depends on it — but until now it
downgraded the connection without a word, and a transport is built per send, so
whoever flipped it had nothing to go on afterwards.
It now warns once per process, naming the host it applies to. Once, not per
mail: a line on every notification is a line nobody reads.
It was written out twice — once in the seeder that creates the account, once
in the config payload the login screen reads it from. The two have to match
exactly or the published demo credentials stop working, which is a poor thing
to leave to copy-paste. It moves next to DEMO_EMAIL_PRIMARY, which the same
comment block already argues for.
Also clears a Sonar S2068 "hard-coded password" on the auth service: the value
is public on purpose, but a literal next to a `demo_password` key is exactly
what the rule looks for.
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.
Writing them out turned three tests red, each for a good reason:
- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
its own copy and swaps the whole node out once the fetch settles. It now
waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
"Not connected" the badge starts as — the test passed whether or not the
button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
It seeds a file first, so the 500 has something to leave alone.
JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
PluginRuntimeService booted enabled plugins from onModuleInit, but Nest fires
same-module onModuleInit hooks in providers-array order, where the runtime
precedes PluginRpcRegistryService — so every boot-activated plugin's rpc host
was bound from a still-empty registry and its first RPC (typically db.migrate
in onLoad) came back PERMISSION_DENIED on every cold boot, until an admin
deactivated and reactivated it by hand. Move the boot reconcile to
onApplicationBootstrap, which is guaranteed to run after every module's
onModuleInit, registry scan included.
Also fix the denial message, which interpolated the plugin id where the
permission belonged ('db.migrate requires a permission "featured-guides" was
not granted'); it now names both the missing permission and the plugin.
* fix(plugins): enforce manifest apiVersion at install and activation
* test(plugins): exercise the absent-apiVersion default
* feat(plugins): generate the core-event catalog into host-facts
* docs(plugins): explain the packing.listBags write-permission binding
* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header
* fix(plugin-sdk): validate settings options and oauth like the host
* fix(plugin-sdk): model the full manifest shape and stop dropping fields
* fix(plugin-sdk): drop non-string routeProfiles icons like the host
* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId
* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host
* feat(plugin-sdk): expose the generated core-event catalog
* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0
* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins
* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export
* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing
* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0
* docs(wiki): bring the plugin pages in line with the current runtime
* docs: bring the workspace CLAUDE.md guides up to date and make them committable
* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates
* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing
* chore(plugin-sdk): 1.6.0
Closes#2019
The route toggle belongs to one day; the reservation list it filtered is the
whole trip. So turning on a day's routing let every transit reservation in the
trip draw its geometry at once, including the ones belonging to other days —
on a day that has no transport of its own, which is what the reporter saw.
Only automated transports rode it, and that is the distinction they drew: a
manually entered train or flight is type 'train'/'flight' and reaches the map
only through its own per-booking toggle. Only the transit search stores
type 'transit'.
Gate the transit branch on the day, and gate only that one — the per-booking
toggle stays trip-wide, because switching a booking's route on is a deliberate
act about that booking. The predicate is the day-order range, not the departure
day: an overnight journey carries end_day_id and has to stay on both of its
days, the same rule the sidebar timeline already applies. Day order rather than
day id, since ids stop being monotonic once days are reordered.
The day context is optional throughout, so a map without one — collections —
keeps behaving exactly as it did. Not a 4.0.0 regression: it reproduces on
3.4.1 too.
Closes#2021
A country is one path but not one landmass. France's is five rings, three of
them Kerguelen, French Guiana and New Caledonia, so fitting a map to its
bounding box asked for 221 degrees of longitude and printed a Paris-Lyon-Nice
route as a single dot. Britain does it through South Georgia, Norway through
Svalbard, Ecuador through the Galapagos — which is why a Netherlands journey
looks perfect and a France one does not.
Satellite only escaped by accident: imagery leaves the shape list empty, so the
fit fell through to the stops. Same control, silently doing nothing.
Fit to the rings the journey is actually near, and keep the whole shape as the
fallback for a map with no route yet. The margin is the route's own size with a
two-degree floor, which is the judgement in this fix: at two degrees a
Paris-Nice route still keeps Corsica, which is the picture that trip wants,
while Kerguelen stays out.
The outlines themselves are untouched — the overseas rings are still drawn,
they just run off the frame the way a coastline does.
Closes#2022
The story preview carried both `block` and `line-clamp-2`. Tailwind emits the
display plugin after line-clamp, same specificity, so `display:block` won and
`-webkit-line-clamp` — which only does anything on a -webkit-box — was inert.
The whole entry text rendered, the card grew with it, and the timeline it sits
in is bottom-anchored with no height of its own, so it climbed up the screen.
Measured at 430x932: three words put its top edge at y=744, four hundred words
at y=24, two thousand at y=-2916. That is the reporter's "at the top in one
journey, in the middle in another" — it is not the install, it is the longest
comment in the journey being opened.
The same growth explains the other half. The timeline sits above the map and is
a horizontal scroller, so once it covers the screen it takes every drag Leaflet
would have got, and only the gap between it and the dock still reaches the map.
Dropping the display utility fixes today's cards; capping the card height means
no future one can do this again. 140px is what the older journey timeline pins
its active card to, so the two look like the same product.
Also lift the map attribution over the dock, which the reporter's screenshots
show sitting on top of it. Both engines, since a phone meets either.
The redirect grew three paths nothing exercised: a malformed APP_URL falling
back to the request host, a request the proxy already terminated TLS for, and
the health probe, which talks plain HTTP on the loopback and must not be sent
to a hostname it cannot resolve. The per-domain coverage ratchet caught the
gap - src/middleware sat at 93.44% against its 94% pin.
safeExternalHref prefixed https:// to anything without a scheme, which turns
a note that reads "call the hotel" into a link to a host that does not exist.
A dot and no whitespace is what separates www.hotel.com from free text.
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.
The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.
The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.
Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.
Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.
The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.
The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).
The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.
safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.
And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.
The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
Same behaviour, and the root lint job stops failing on no-control-regex. The
stripping is the point and has to stay: browsers drop everything at or below
U+0020 before they resolve a scheme, so a tab spliced into 'javascript:' is
still a javascript: URL to them.
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.
The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.
The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.
And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.
The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.
TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.
Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.