The tours map gets a 3D mode on the same map surface between the side panels:
a "3D" switch in the layer pill drapes the route over open Terrarium elevation
tiles (AWS Open Data), colours it by grade, plays a flyover tied to the
elevation profile in the rail, and keeps planning working by clicking and
dragging waypoints on the terrain. The corridor around the route is preloaded
before the flyover so the landscape never loads in front of the camera.
GPX imports no longer get a made-up hiking difficulty: max_hiking_difficulty
becomes nullable (new migration, waypoints kept), the import writes null and
the client shows no T badge for it. Drawn tours still default to T2.
Info hints in the planner show their tooltip only, and the discard
confirmation reads "Discard changes".
The column showed the last sign-in, which a renewing cookie leaves weeks
behind for someone using TREK daily. It now shows the later of the sign-in
and the last request. Saving an edit no longer blanks the row's avatar,
activity and online dot, and the list refreshes every 30 seconds while open.
Journeys from the transit search are routing, not bookings, and a day of them
pushed the real bookings out of the six slots. Booked trains and buses keep
their own types and stay. The MCP tool reads the same feed.
This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.
Included feature blocks:
- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:
- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:
- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:
- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
An SSO login that matched no account by its subject took the account with the
same email address and linked the provider identity to it. That address proves
nothing when the account typed it in itself: anyone could sign up with, or
switch their profile to, a colleague's address and receive the colleague's
first SSO login. The provider switch branch (#2110) relinked a row on any email
match without even asking for email_verified, and also for another subject of
the same provider.
users.email_self_asserted remembers an address the account set itself: signup
(not the first account) and an email change in the profile settings set it, an
admin entering a new address clears it, and accounts the provider creates start
without it. Existing accounts keep linking, except those whose address moved
away from the registered one without an admin editing it. A login by email now
needs a verified address and an unmarked account, and is refused for another
subject of the same issuer, with email_link_blocked and a translated message on
the login screen. An email change in the profile settings writes a
user.email_change audit row.
Refs #2110
The create and update answers, the reservation:updated broadcast and the MCP
reservation tools all hand back the single booking read, and that read never
loaded reservation_day_positions. The day list puts a booking moved on a day by
that slot before its time, and the client replaces the booking with the answer,
so a booking moved on a day jumped back to its time after every edit until the
next reload. The read now attaches day_positions in the shape the trip list
already sends.
Refs #2631
Marking a region wrote its country with source 'manual', the same as ticking
the country off by hand, and removing the last region then took the country
away whatever its origin, Dawarich confirmations included. A country that
only comes in through a region is now stored as 'region', marking it visited
afterwards turns it into the user's own mark, and the cascade in unmarkRegion
only runs for a country without such a mark. Readers still see 'manual' for
those rows, so the country detail, the MCP resource and the plugin RPC answer
as before. Rows written before this change stay 'manual' and keep their
country.
REST, MCP and plugin RPC share the service. The atlas popup on desktop and
phone now asks the server whether the country is still marked before it
drops it from the map instead of guessing.
Refs #2597
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
src/services is gone, so the folder named a layer that no longer exists. The airtrail suites go to tests/unit/nest/integrations, the crypto, cookie, avatar, password policy, timezone and conflict helpers to tests/unit/nest/common, the kitinerary mapper to booking-import, the photo media suite to photos, the atlas bundle guard to tests/unit/nest and the two migration suites to tests/unit/db. Only the relative import paths change, and the format baseline follows the new paths.
Prettier on every new or moved file that was not formatted, so lint:format
holds them; the files taken off the format baseline are the ones that moved
or were formatted on the way.
UserImmichRepository, UserSynologyRepository and UserAirtrailRepository hold the eighteen connection statements UsersRepository carried, on the same users table and with their statements unchanged, provided through a small module as factories over the EntityManager. ImmichService, SynologyService, AirtrailService and JourneyService inject them instead of the account repository. UserRow drops the dead immich_access_token column (the table keeps it), and a shape test pins the account repository's size.
The ICS export reads the stops and notes of all dated days in two statements instead of two per day, the journey's full read and entry list take the source trip titles in one join instead of one read per entry, and reordering a day's stops or sorting it by time writes every position in one CASE update. Results are unchanged: each day keeps its order (the id settles a tie), a missing trip still names null, and an id listed twice keeps its last position.
A DbTimestamp brand, produced only by dbNow(), types the expiry writes of OAuth tokens and password reset links, and collection edits stamp updated_at the same way. parseDbTimestamp() reads the stored text as UTC and replaces the oauth-local parser; utcSuffix lives once beside it. A data migration rewrites the ISO values those writers left behind, and the OAuth session lists keep answering the expiries in ISO.
Two outputs change on purpose. collections.updated_at used to come back in two spellings: a collection never edited answered the column default (2026-01-02 03:04:05), an edited one the ISO text its writer stored (2026-01-02T03:04:05.678Z). It now answers the canonical text in every case, in the PUT and cover responses and, once the migration has run, in every read; the shared schema types it as a plain string and the client does not read it. The ISO expiries of the OAuth session lists are rebuilt from the stored second-precision text, so they end in .000Z instead of carrying the milliseconds of the write.
shared's lint ran eslint --fix with prettier/prettier as an error, so in
lint-prettier.yml the format:check after it only ever saw files it had just
rewritten, and the server's format scripts pointed at a test/ directory that
does not exist. Now lint is check-only in both (lint:fix applies fixes), the
server scripts cover src/ and tests/, and a lint:format ratchet on the shared
library holds every file outside scripts/format-baseline.json to Prettier in
both workspaces: in the server job of test.yml and in lint-prettier.yml,
which keeps providing the lint check. Formatting leaves ESLint in shared, and
the unused eslint-plugin-prettier goes. Once the tree is reformatted in one
commit, --update empties the baselines and the check is a full prettier
--check.
The specs written alongside the session, transaction and lifecycle work
still seeded and read with raw statements. They go through the ORM
factories now; the few that test SQL below the ORM carry a test-sql-allow
reason. The raw SQL baseline holds 177 statements in 6 files.
acquire() made sure a row existed and then raced on a conditional UPDATE,
two statements the registrar wrapped in a transaction of its own. lint:tx
cannot follow a repository reached through a local variable, so it
reported the method. One INSERT ... ON CONFLICT DO UPDATE ... WHERE now
takes a free lease, renews the holder's and leaves anyone else's alone,
atomically on SQLite and on Postgres, and needs no transaction.
These specs left the raw SQL baseline but still emptied tables with
`db.exec('DELETE FROM ...')` and aged a Dawarich connection with a raw
UPDATE, which the ratchet cannot see because it only counts `.prepare(`.
They now go through deleteRows and updateRows like the rest of the file.
packing_item_recipients and packing_bag_members have no entity; both
cascade from their item and bag, so the packing spec relies on that with
foreign keys on in the snapshot database.
Converts the raw prepare() fixtures and post-condition reads in the users
repository spec to updateRows, findRow and readUser. The Immich read
helper becomes async and still returns only the three columns its toEqual
assertions compare, and the guest insert check compares its columns with
toMatchObject now that the read returns the whole row.
The raw statements that remain carry a test-sql-allow marker: the legacy
statements run as parity oracles for the repository reads, the
NOT NULL DEFAULT 0 probe on is_guest, which has to bypass the entity
default, and the unflushed-entity check, which must read the table
without an EntityManager that could flush first.
The reservation visibility parity harness now stages bookings, links stays
and reads the trip's days through the ORM factories and readers. The
legacy string fragments it holds the typed predicates to keep running raw
behind allow markers, since they are the fixed side of the comparison.
Converts the raw prepare() fixtures and reads in the places integration
suite and the trip members repository spec to the ORM factories and
readers. Tags, categories and linked expenses are seeded through makeTag,
makeCategory and makeBudgetItem; the member rows the roster cases backdate
or point at an inviter are written through insertRow and updateRows. The
legacy UNION and join statements the roster reads are held to stay raw
behind allow markers.
Converts the raw prepare() fixtures and reads in the demo repository spec,
the transports MCP spec and the immich integration suite to the ORM
factories and readers.
Where a case compared a narrow SELECT with toEqual, it now picks the same
columns off the row the reader returns, so the comparison stays exact
rather than loosening to a partial match. The immich album link cases seed
their photos through addTripPhoto and addAlbumLink, which register the
provider asset the same way the raw INSERT OR IGNORE did.
Converts the raw prepare() fixtures and reads in the plugin discovery spec,
the permissions service spec, the feeds and invite MCP spec and the day
accommodations repository spec to the ORM factories and readers.
The permission cache cases still write their override rows past
savePermissions, now through setAppSetting, so the stale-cache behaviour
they pin is unchanged. The day accommodations spec keeps the legacy
statements it holds the repository to behind allow markers.
Converts the raw prepare() fixtures and reads in the collections e2e, the
storage registry service spec, the transit MCP spec, the MCP token
repository spec and the immich memories integration suite to the ORM
factories and readers.
The collections e2e drops the raw canAccessTrip on its database mock, which
nothing reads any more. Reads that used to join trip_photos to trek_photos
in SQL resolve the photo row through the readers instead, and the orphan
trip photo of IMMICH-057 is still written with foreign keys switched off,
now through the ORM on the same connection.
Converts the raw prepare() fixtures and reads in the trip read model
service spec and the share token and audit log repository specs to the ORM
factories and readers.
The trip read model spec drops the raw canAccessTrip and isOwner it still
put on the database mock; nothing has read them since trip access moved
onto the repositories. The share token spec now inserts through
makeShareToken and reads what a write left behind through findRow, keeping
the SELECT * row and the legacy statements it is held to as allowed
oracles. The audit log spec keeps the raw row and the typeof() storage
class probe behind allow markers, since both are about what SQLite stores.
Converts the raw prepare() fixtures and reads in the plugin settings
isolation spec, the tags MCP spec, the place ratings and days repository
specs and the receipt scan e2e to the ORM factories and readers.
The receipt scan e2e writes the AI parsing addon config as the object the
entity's JSON column maps, not as a pre-serialised string, and seeds its
users and trips through makeUser and makeTrip with the ids its session
cookie is signed for. The days spec keeps the SELECT * row it compares the
ORM's rows with key for key, and its legacy statements, behind allow
markers; the place ratings spec backdates updated_at with dbNow instead of
datetime('now').
Converts the raw prepare() writes and reads in the websocket connection
suite, the web push and instance API key services, the days MCP spec and
the packing visibility and place details cache repository specs to the ORM
factories and readers.
The websocket suite reads the app's own ORM; the password version bump that
was a single relative UPDATE now reads the user and writes the next
version. The packing visibility parity keeps the legacy fragment it is
checked against behind an allow marker.
The visited countries and place regions repository specs seed their rows
through the ORM; the legacy statements they hold the repositories to stay
raw behind an allow marker, since those are the oracles the parity is
checked against.
The storage admin e2e moves from hand-rolled tables to the migrated
snapshot, seeds its two users through the factories with the ids their
session cookies are signed for, and reads the stored backends and the audit
rows through the readers instead of raw SELECTs.
Converts the raw prepare() fixtures and reads in the share and place shadow
e2e suites, the google quota, travel stats and MCP guard services, the
memories MCP spec, the admin seeder spec and the assignment participants
repository spec to the ORM factories and readers.
The place shadow e2e moves from hand-rolled tables to the migrated snapshot
with its users pinned to the ids their session cookies are signed for. The
share e2e drops the raw canAccessTrip it still put on the database mock:
nothing has read it since trip access moved onto the repositories. The
quota spec computes yesterday and the retention window in UTC, the same
calendar day SQLite's date('now') gives. The participants oracle keeps its
raw statement behind an allow marker.
Converts the raw prepare() fixtures and reads in the next set of specs to
the ORM factories and readers: the password reset, webauthn, dawarich,
budget, to-do and vacay repository specs, the journey, settings, addons and
share MCP specs, the plugin POI and operator egress specs, the feeds,
storage stats and query helper services, and the packing, collections,
day reorder, leg mode, upload context and trip access integration suites.
The notifications and google quota e2e suites now seed their users through
makeUser with pinned ids, and the notifications suite moves from hand-rolled
tables to the migrated snapshot. Legacy statements that serve as a parity
oracle and the raw rows the primary-relation spike holds toObject() against
stay raw, each with an allow marker naming why.
Converts the raw prepare() reads and writes in the share, airtrail, files,
todo, dev-link, vacay, tours, bucket list, unit-of-work and plugin guard
specs to the ORM factories and readers, so they go through the entities the
app uses instead of hand-written SQLite.
The vacay e2e moves from hand-rolled tables to the migrated snapshot, with
its user pinned to the id its session cookie is signed for. Statements that
are raw by nature keep the allow marker with the reason: the legacy oracle
of the bucket list parity spec, the PRAGMA probe of the trek_photos
migration, the unmapped probe table of the route import rollback, the
behind-the-ORM reads of the entity insert probes, and seedUser, which also
serves the hand-rolled users table of createTempDb. Comments that quoted a
prepare() call are reworded so the ratchet no longer counts them.
The case checks the text format revokeAllForClient and revokeById write.
Reading it through the entity runs DbTimestampType, which turns an epoch
integer or a Date into formatted text, so an integer write would still
pass. The two reads go back to raw statements under an allow marker and
assert the stored value is a string before matching its format.
The places repository suite now edits its fixture places, writes tags,
ratings, tours, regions, stops and tracks, and reads back what a write
left through the ORM factories. The datetime('now', ...) offsets the old
statements computed in SQL are the same UTC second-resolution strings,
computed by sqliteNow, and the full-column fixture of PLACEREPO-050 goes
through insertRow with every column it named.
Kept raw and marked: the legacy getPlaceWithTags composition, the listForTrip
statement builder and every other legacy projection the typed reads are
compared against, plus the UPDATE in PLACEREPO-013 that must land behind
the ORM's back.
The reservations repository suite now shapes its bookings, trips, stops,
stays, legs and day positions through the ORM factories, takes its day
ids from findRows and reads back the AirTrail and review columns a write
left with findRow.
Every legacy statement the typed reads are compared against stays raw
and marked as the oracle, the calendar, upcoming, share and public API
projections among them.
The trips repository suite now edits its fixture trips and reads back
what a write left through the ORM factories (setTrip, tripRow,
findRow), and promotes its admin through updateRows.
The parity statements stay raw and marked: canAccessTrip, the owner
check, the viewer, active trip, feed token, travellers, sync and share
projections are the oracles the repository reads are held to. Also kept
raw: the SELECT * snapshot toObject has to reproduce, and the UPDATE in
TRIPREPO-022 that must land behind the ORM's back.
The collab and budget e2e suites, the MCP integration suite, the budget
MCP tools and the OAuth token repository suite seed and read through the
ORM factories. The collab e2e suite moves off its hand-rolled CREATE
TABLE set onto the migrated snapshot, the way the factories guide asks of
a suite that converts: an entity reads and writes every mapped column,
which a trimmed table does not have. Its users are seeded with the ids
the session cookies are signed for.
The recursive chain statement in the token suite stays raw, marked as the
legacy oracle collectChainIds is held to.
Eleven more suites (atlas and settings e2e, the bucket list, shadow pick
and Dawarich suggestion repositories, the budget and collab MCP tools,
notification preferences, VAPID keys, school holidays and tours) write
and read their rows through the ORM factories. Time-relative fixtures
that leaned on SQLite's date('now', ...) build the same text in JS with
dbNow(), and helpers that returned undefined for a missing row keep doing
so where an assertion depends on it. The legacy statements the parity
cases compare against stay raw, marked as oracles.
The repository suites now write their fixtures and read back what a call
left behind through the factories and readers, not hand-written SQLite on
the better-sqlite3 handle. What stays raw is marked with test-sql-allow:
the legacy statements the parity cases hold a repository read to, rows
read as the table stores them (a BLOB, an integer the entity maps to a
boolean), and a write that has to land on the connection an open
transaction holds.
Cases that pinned a "raw UPDATE" between two repository reads now make
that write through a context of their own, which the repository's
identity map knows as little about, so they still prove the same thing.
The suites with one to three raw statements left now seed and read their
rows through the ORM factories instead of hand-written SQLite on the
better-sqlite3 handle. A legacy statement that serves as a parity oracle,
the schema introspection and the bare backup file keep their raw SQL and
say why with a test-sql-allow marker. Two database mocks still answered a
getPlaceWithTags the module no longer exports; that dead branch goes too,
and the budget seam suite stubs the reservations repository directly
instead of a fake prepare().
The demo seed parity case, the transaction yield probe and the test
UnitOfWork check read their row counts and rows back with raw
statements; they now use the ORM readers.
The statements these files are about stay raw and are marked: the
dirty read and write the transaction yield probe measures on the shared
handle, and the reset-admin.js case, whose database file has no ORM
bound to it.
The repository suites for assignments, notifications, document sync,
tags, photo metadata, vacay, day notes, notice dismissals, idempotency
keys, settings, travellers, invites, providers, hidden regions, road
trip tracks and the collection role check seeded their fixtures and
read back what a write left behind with raw statements. Fixtures now go
through the ORM factories and insertRow/updateRows, and the state reads
through findRow/findRows/countRows.
What stays raw is raw on purpose and carries a test-sql-allow marker:
the legacy statements every parity case compares a repository against,
the full raw rows used as such an oracle, PRAGMA reads, typeof() and
date() storage checks, and the out-of-band writes the identity map
freshness cases are about, which have to bypass every EntityManager.
A session renewed from a token issued before sessions were tracked has
an id derived from that token, and the renewal inserts it only when no
row by that id exists. The nightly purge deleted revoked rows straight
away, so the next request with the still valid old token inserted the
session again as an active one and got a fresh tracked token with a new
lifetime. A logout, an ended session or a sign out of the others held
only until the purge ran.
The purge now deletes expired rows only, and a revoked row stays until
its own expiry. A derived row is recorded with an expiry no earlier
than the old token's, so by the time it goes the old token is refused
by its signature check anyway.
The session e2e suite drove login, logout, the session routes, a
password change and renewal, but nothing showed end to end that a
password reset, an MFA disable, account deletion or an admin action
makes the old cookie stop working, the purge job had only a unit test
with a stubbed service, and reset-admin.js had no test of its session
handling at all.
sessions.e2e.test.ts now also resets a password through the mailed
token (both devices get 401, every row is ended, the new password signs
in), turns two-factor off from one device (the other gets 401, this one
stays), deletes the account (the rows go with it) and runs
SessionPurgeJob.tick against the real table (ended and expired rows go,
the live session keeps working). The admin paths need AdminModule and
with it most of the app, so session-revocation.e2e.test.ts boots
buildApp(): an admin setting a password, clearing two-factor and
deleting a member each sign both of the member's devices out and leave
the admin signed in, while an edit that leaves the password alone signs
nobody out. reset-admin-sessions.test.ts runs the script against a
database with session rows (only the reset account's live ones end) and
against one without the table.
A token without a jti past half its life is renewed on every request
its cookie authenticates, and each renewal minted a session with a
random id. A first page load sends many requests at once, so one browser
ended up with several active sessions; only the last cookie survived,
and the rest showed up in GET /api/auth/sessions as separate devices
until they expired, up to 30 days for remember me.
The interceptor now hands the token to SessionsService.renew, which
derives the session id from it (a SHA-256 of the token, laid out as a
version 8 UUID so it passes the same id check) and inserts the row only
if it is not there yet. Every renewal of the same token lands on the
same session; if that session was ended in the meantime nothing is
renewed, just as for a tracked token. A caller that passes no token gets
the old behaviour.
Both swap the whole database file. The demo reset copies a baseline
saved at first seed, before anyone logged in, so it brings back an empty
user_sessions table; a backup restore brings the backup's own rows, or
none for a backup from before sessions were tracked. A session token
whose row is missing is refused, so the hourly reset signed every demo
visitor and the demo admin out, and the admin who ran a restore got a
401 on the next request. Before session tracking only the password
version applied, and those tokens survived.
Both paths now read the active rows with their owner's email before the
swap and put them back after the reopen, in a fresh fork, for every
account the new file still holds under the same id and email. A row the
new file already has is left as it is, so a session that ended there
stays ended, and a session whose id now names somebody else (a backup
from another install) is dropped instead of handed to them. The password
version check still refuses a token the restored account no longer
matches. Reading or writing them is best effort and never fails the
reset or the restore.
A session token was a signed JWT and nothing else. Logout only cleared
the cookie, a 30-day "remember me" token stayed valid wherever a copy of
it had ended up, and nobody could sign out one device short of changing
the password or rotating the JWT secret for everyone.
Every session token now carries an id (jti) naming a row in the new
user_sessions table (id, user_id, created_at, last_seen_at, expires_at,
revoked_at, user_agent cut to 256 characters; no address is kept). The
rows live behind UserSessionsRepository and the new sessions domain,
whose SessionsService is the one issuer: AuthService.generateToken and
the OIDC sign-in both go through it, and sliding renewal re-signs a
tracked token under the same id and moves its expiry. verifyJwtAndLoadUser
takes the repository and refuses a token whose session is revoked,
expired or gone, refreshing last_seen_at at most every five minutes.
A token without a jti was issued before this change; it passes until its
own expiry (or a password change), so the upgrade signs nobody out, and
renewal turns it into a tracked session.
Sessions end on logout (the one the request names, verified even when
expired), on a password change (all, the device gets a new one), a
password reset, an admin setting a password, reset-admin.js, an MFA
disable (all but the current one), an admin clearing a user's MFA, and
with the account. GET /api/auth/sessions lists the active sessions,
DELETE /api/auth/sessions/:id ends one and POST
/api/auth/sessions/revoke-others ends all but the current, with their
contracts in @trek/shared and two new audit actions. There is no UI for
them yet. MCP exposes no account or session tools, so there is no
parallel tool to change; an MCP call made with a session JWT as bearer
goes through the same check.
The token in the login, register, demo-login, MFA and passkey bodies
stays for API clients but is marked deprecated in the API docs and in
the client type, which never read it.
A nightly session-purge job on CronRegistrarService deletes the expired
and revoked rows. The migration is 042500. The sessions domain gets its
own coverage entry at the 80 floor until a full run measures it.