Commit Graph

248 Commits

Author SHA1 Message Date
Maurice 00a19679f5 style(server): prettier on the test files this branch touched 2026-10-12 20:01:22 +00:00
Maurice fbba8f96b7 fix(planner): road trip mode, the dawarich trail and the route overview follow the account to another browser
The three planner view switches lived in sessionStorage only, so a new tab,
another browser or another device opened every trip on the plain view again.
They are now kept per trip in one user setting, planner_trip_views, validated
against a contract in @trek/shared. The server keeps only switches that are on
and only trips the user can still open, so a deleted trip or one the user left
drops out on the next write. The tab copy in sessionStorage stays and answers
until the settings have loaded.

Refs #2639
2026-10-12 17:41:56 +02:00
Maurice 1b58acc78f fix(trips): deleting a guest keeps the costs adding up instead of dropping their payments
A guest's payer rows and recorded payments hung on the users row by ON DELETE
CASCADE, so deleting the guest wiped money that had really moved: expenses
they paid were left without a payer, settled payments reopened, and a custom
or receipt split lost the guest's share so the balances no longer summed to
zero.

Deleting a guest who paid for an expense or is part of a recorded payment is
now refused with a 409 (REST and the delete_trip_guest MCP tool, same text),
and the share dialog says to change the payer or delete the payment first.
Otherwise the guest's share is handed on: an equal split re-splits as before,
a custom amount goes to the others by largest remainder in the currency's
smallest step (members without an amount get one, so the expense stays a
custom split), and a receipt drops the guest from its lines and is split
again like the cost editor does. A legacy paid_by_user_id is cleared as on
account deletion. Account deletion goes through the same re-split. The
confirmation text in all languages and the wiki say what really happens.

Refs #2604
2026-10-12 16:39:17 +02:00
Maurice 9064dbb2f0 fix(costs): an expense made from a booking is dated on the booking day
Creating the expense from a booking opened the editor on today, and the
cost a booking import writes by itself had no date at all, so it sat under
"No date". Both now take the booking's own date, else a hotel's check-in
day, else the day the booking sits on. The server sets it for every
booking write that creates a cost, so REST, MCP, plugin RPC and the import
agree.

A start time typed on a booking with no day picked no longer borrows
today's date on desktop. Today was mostly outside the trip, the server
then moved the booking onto day 1 while the list kept today. Desktop now
does what the phone already did and keeps the time without a date.

Refs #2604
2026-10-12 15:04:22 +02:00
Maurice 33e606959c fix(budget): settle-up hands out whole won and yen, so balances add up and settle clears them
The ledger nets every currency in hundredths, and the settlement handed those
hundredths out as they were. For a currency shown without decimals the panels
then rounded each balance and flow on its own: 10,000 KRW three ways read
+6,667 / -3,333 / -3,333, two flows of 3,333 sat under "You're owed 6,667",
and Settle booked 3,333.34 won.

The balances, flows and final budget lines are now allocated by largest
remainder in whole steps of the display currency (whole won or yen, unchanged
for two and three decimal ones), so the shown set adds up to zero and the flow
amount Settle sends is the whole amount on the row. The ledger, stored rows
and manual settlements are untouched. REST and the MCP settlement summary
share the same method.

Refs #2604
2026-10-12 14:28:06 +02:00
Maurice ef150439f9 fix(vacay): leave days follow a moved trip as a whole block and never land on blocked days
Moving a trip's start shifted the owner's vacay entries one row at a time,
checking each target against rows that were about to move themselves. A
block shifted by less than its own length kept its first days behind and
pushed the rest onto the weekend, even with weekends blocked.

The shift now decides every day before writing: a day whose target is a
blocked weekend or a whole company holiday is dropped, as is one whose
target already holds leave that is not moving; a half company holiday
leaves half a day. The rest move in the direction of travel inside one
transaction. The plan is the one the owner is active in, so a member of a
fused plan gets their days moved too, and a trip edit no longer creates a
vacay plan for an owner who has none. Open vacay views are notified after
the trip update commits.

The pure vacay helpers moved to vacay.helpers.ts and the trip window tests
to their own suite, so both files stay inside their size budgets.

Refs #2604
2026-10-12 14:12:36 +02:00
Maurice af66bdd910 fix(budget): saving an expense keeps the payers and shares of people who left the trip
Removing someone from a trip leaves their payer and split rows on the
expenses they were part of. Every save ran the ids the form sends back
through the roster filter, so an unchanged save dropped them, total_price
shrank to what the rest had paid and a custom split stopped adding up.

Ids already on the expense now stay allowed, as payer or as member, and
can still be taken off it on purpose. A new expense and an id that never
was on the expense are filtered as before (#1952). The paid ticks of the
split carry over a form save instead of being reset. REST, the plugin RPC
and the MCP split checks all read the same allowed set from the service.

Refs #2604
2026-10-12 13:15:17 +02:00
Maurice eaab7dc4e4 fix(reservations): a booking read on its own carries its day slots, so an edit no longer drops them
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
2026-10-12 12:32:24 +02:00
Maurice 8de40db40e fix(calendar): a timed stop without an end time ends after the place duration
A stop with a start time but no end time went out as a VEVENT without
DTEND. Calendar clients fill in their own default length for that, mostly
an hour, so a 20 minute stop showed up as an hour. The event now ends at
start plus the place's duration_minutes when that is set and above zero,
carried over midnight like the stay markers. An explicit end time still
wins, so start and end under an hour stay as they were. The trip download,
both calendar feeds and the MCP export share this builder.

Refs #2641
2026-10-12 10:05:24 +02:00
Maurice aa1bb41cf5 fix(collections): moving a place out of a list needs the same role as deleting it
Moving a saved place to another list takes it out of the source list, but
updatePlace only asked for edit rights there, so an editor could empty a
shared list they may not delete from. The source now takes owner or admin,
like a delete, and the target still takes edit rights. REST and the
update_collection_place tool share the service path. The Move to list
action on desktop and phone follows the delete permission.
2026-10-12 09:17:08 +02:00
Maurice 724c88c201 style: format server and shared with prettier
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.
2026-10-10 21:23:37 +02:00
Maurice fa0edfd714 test(server): suites record broadcasts on a FakeRealtimeService instead of mocking src/websocket
A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
2026-10-10 20:16:10 +02:00
Maurice 00cbc5aae3 test(server): src/config takes fixed test values from the setup file, so suites stop mocking it
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
2026-10-10 20:16:10 +02:00
Maurice e21f2d97b6 feat(contracts): vacay and journey routes declare their response schemas
The vacay plan, calendars, years, entries, stats and shares, and the
journey list, create, tracks, stats, share link and every success answer
get response schemas in shared and a @ResponseContract on their handler.
The holiday proxies pass the provider's answer through and are marked
exempt. The journey e2e stubs return whole rows, as the service does.
2026-10-10 20:16:08 +02:00
Maurice 8e01dbb92f feat(contracts): todo, settings, collab and file routes declare their response schemas
shared gains the response schemas these routes answer with (rows in their
storage spelling, as the routes have always sent them) plus the common
success and empty answers, all exported for the client to type its calls
with. Every JSON handler in the four controllers carries its
@ResponseContract; the file download, which streams, is marked exempt. The
files e2e fixture table gains the note_id column the real one has.
2026-10-10 20:16:08 +02:00
Maurice 0cedff94a0 refactor(maps): wikimedia lookups and brand logos move into their own client 2026-10-10 20:16:02 +02:00
Maurice 79ba003bfe refactor(maps): nominatim and overpass calls move into an OpenStreetMap client 2026-10-10 20:16:02 +02:00
Maurice 806f3c8e62 refactor(addons): the transit-provider setting and photo-provider routes move into the shared kernel
AddonsService read the transit backend through transit/transit-provider.ts
and built photo-provider endpoint paths with memories.helpers, so the addons
domain, which nearly every domain imports for its gates, depended on
transit and memories. Both are free functions over a repository or plain
strings; they move unchanged to common/, where the transit and admin sides
read them too.
2026-10-10 20:15:59 +02:00
Maurice da97d7535f test(server): follow the signatures the session and port work changed
The session gate passes a session lookup to verifyJwtAndLoadUser, the
Immich save takes auto_upload, getAppUrl stays synchronous, the demo
session list marks the current session by the id it is handed, and the
lease no longer needs a transaction of its own, so its spec is gone.
2026-10-08 20:22:16 +02:00
Maurice 37af6f4ff9 test(server): seed bookings and places the way the services store them
The converted specs wrote accommodation_id as a plain id, while the
services store the legacy '<id>.0' text, and wrote read-only foreign key
twins instead of their relations. The files e2e schema gains the trip
columns the entity fills, and the idempotency spec seeds a real user.
2026-10-08 20:22:16 +02:00
Maurice be016f180e test(server): seed the session, lease and rollback specs through the factories
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.
2026-10-08 19:05:04 +02:00
Maurice e90ed065e5 test(server): clear and age fixture rows through the ORM in converted specs
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.
2026-10-08 18:47:25 +02:00
Maurice 63bcf61a2b test(server): seed the system notice, packing e2e and synology specs through the factories
Converts the raw prepare() fixtures and reads in the system notices
integration suite, the packing e2e and the synology memories integration
suite to the ORM factories and readers.

The packing e2e moves from its hand-rolled tables to the migrated snapshot,
seeds its three users with the ids their session cookies are signed for,
and drops the raw canAccessTrip on its database mock, which nothing reads
any more. The synology suite now shares one ORM handle for the photo
registration helpers and the readers instead of declaring a second one
halfway down the file.
2026-10-08 18:46:50 +02:00
Maurice e9ff28d84a test(server): seed the files MCP and roadtrip e2e specs through the factories
Converts the raw prepare() fixtures and reads in the files MCP spec and the
roadtrip e2e to the ORM factories and readers.

The files spec seeds its trip files through makeTripFile and its links
through linkFile, keeping the helper's explicit-null handling for size and
type. The roadtrip e2e keeps its pinned trip, day and place ids, which its
routes address directly, now written through insertRow and insertRows.
2026-10-08 18:46:50 +02:00
Maurice 9104dab183 test(server): seed the photo cache and dawarich specs through the factories
Converts the raw prepare() fixtures and reads in the trek photo cache and
google place photo cache services and the dawarich e2e to the ORM factories
and readers.

The place photo cache suite moves from its hand-rolled three-table fixture
to the migrated snapshot: the references it checks are now real places on a
trip and real saved places in a collection, seeded through makePlace and
makeCollectionPlace, which a table without names or owners could not hold.
The dawarich e2e drops the raw canAccessTrip on its database mock, which
nothing reads any more.
2026-10-08 18:46:49 +02:00
Maurice 855c4e3ba3 test(server): seed the collections, storage registry, transit, token and immich specs through the factories
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.
2026-10-08 18:46:48 +02:00
Maurice a7fe990c6e test(server): seed the plugin settings, tag, rating, day and receipt specs through the factories
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').
2026-10-08 18:46:48 +02:00
Maurice 7349ee95c2 test(server): seed the atlas repository and storage admin specs through the factories
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.
2026-10-08 18:46:47 +02:00
Maurice 9de9719a02 test(server): seed the share, quota, photo and stats specs through the factories
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.
2026-10-08 18:46:47 +02:00
Maurice e3982a8e2a test(server): seed the repository, MCP and e2e specs through the factories
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.
2026-10-08 18:46:47 +02:00
Maurice 894a5ee781 test(server): seed the small leftover specs through the repositories
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.
2026-10-08 18:46:47 +02:00
Maurice 5947631db6 test(server): seed the days, tours, assignments and journey share specs through the factories
The days and tours e2e suites, the assignments service suite and the
journey share suite build their fixtures through the ORM factories and
read back through findRow/findRows/countRows. Fixtures that pin ids (the
trip, day and places a session cookie or a route targets) keep them.
Readers that the old suites called synchronously (order, dayOrder,
viaAnchors, insertJourneyPhoto) are async and awaited, Promise.all where
a case compares several at once.

Left raw and marked: the legacy statements the parity cases hold the
repository reads to, the COLLATE NOCASE lookups that prove the token
match is exact, and total_changes(), which counts writes on the
connection itself.
2026-10-08 18:46:37 +02:00
Maurice a3781ed27b test(server): seed the collab, budget, MCP and OAuth token specs through the factories
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.
2026-10-08 18:46:37 +02:00
Maurice 49ba73fcae test(server): seed the atlas, settings, budget and notification specs through the factories
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.
2026-10-08 18:46:26 +02:00
Maurice e2ce2ad05c test(server): seed the service, MCP and route specs through the ORM factories
Service, MCP, integration and e2e suites with up to a dozen raw
statements now seed and read through the factories. A buildApp() suite
uses the app's own ORM, a partial-module e2e or a unit suite a
createTestOrm() or sharedTestOrm() handle on its database. Reads that
used to come back undefined now come back null, and the assertions say
so; where a projected SELECT was compared whole, the same columns are
picked off the row.

Two reads inside the scheduler's delivery callback stay synchronous raw
statements, marked, so they keep seeing the row at the moment of
delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in
the module no longer exports.
2026-10-08 18:46:25 +02:00
Maurice b2f2aef016 test(server): seed the small specs through the repositories
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().
2026-10-08 18:46:13 +02:00
Maurice ba28b5538d test(server): keep the legacy TEXT shape of a booking's stay link
reservations.accommodation_id is TEXT, and every writer stores a stay id
in the shape the legacy statement bound it (`14.0`, see
legacyBoundIntegerText). The converted specs wrote and matched the bare
`14` instead, so a lookup of a booking the service had linked would miss
it and a seeded link would not look like one the app writes. They now go
through legacyBoundIntegerText, the same helper the services use.
2026-10-08 18:45:57 +02:00
Maurice 583ba17606 test(server): seed the e2e specs through the repositories
The e2e suites for assignments, places, bookings, trips, the public API,
doc sync, addons, transit, to-dos, admin, invite links, school holidays,
categories, feeds and plugin search seeded and inspected their database
with raw better-sqlite3 statements. They now go through the ORM
factories and readers, the way the repositories write and read.

Six of them (public API, transit, to-dos, admin, invite links,
categories) still built a handful of hand-rolled tables. They move to
the migrated snapshot, since an entity reads and writes every mapped
column; where the real schema keeps usernames unique, the seeded users
get distinct names and keep the ones the assertions read back. The dead
canAccessTrip stubs left in two database mocks are dropped: nothing in
src imports it from db/database any more.
2026-10-08 18:45:20 +02:00
Maurice 1bba65634c test(server): seed and read test data through ORM factories
The server suite seeds and reads its database through some 5,500 raw
better-sqlite3 statements on the sync handle. They restate the schema by
hand, skip the repositories the app goes through and run on SQLite only,
so nothing in the suite could ever exercise the dialect layer on another
engine.

tests/helpers/factories/ adds typed factories per domain (users, trips and
days, places, assignments, bookings, budget, packing, to-dos, files,
journeys, tours, collections, notifications, settings, plugins, tokens,
atlas, vacay, photos, collab) and generic readers (findRow, findRows,
countRows, updateRows, deleteRows) that go through MikroORM in a request
context of their own, against the app's ORM or a createTestOrm handle.
They refuse a write to a read-only foreign-key twin, which MikroORM would
otherwise drop without a word. The README there is the conversion guide.

The categories integration suite and the tags and calendar-feed e2e suites
are converted in full as worked examples; the tags suite also moves from
hand-rolled tables to the migrated snapshot.

lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left
to a per-file baseline: growth and stale entries fail, files whose subject
is raw SQL are exempt with a reason, and a single statement can be allowed
with a test-sql-allow comment. CI runs it next to lint:boundaries.
2026-10-08 18:44:39 +02:00
Maurice be751ad521 fix(auth): start no new session for a password change over a Bearer token
A password change ends every session of the user and re-issues one for
the device it was made on, handed out as the session cookie. A caller
that authenticated with a Bearer token never reads that cookie, so the
new session sat in the session list as a live device nobody held until
it expired.

The controller now asks for the replacement session only when the
request came with the session cookie, the same check the sliding
renewal makes. The response body is unchanged.
2026-10-08 18:44:08 +02:00
Maurice 648f63c626 fix(sessions): keep an ended session's row until it expires
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.
2026-10-08 18:44:08 +02:00
Maurice e94525afd3 test(sessions): cover every path that ends a session end to end
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.
2026-10-08 18:44:00 +02:00
Maurice 0dde01baa2 fix(oidc): sign in after SSO on a success flag, not the deprecated token
The token in the sign-in bodies was declared deprecated and "read by
nothing of ours", but the web client still decided an SSO sign-in by
reading data.token from GET /api/auth/oidc/exchange, an endpoint the
deprecation notice left out. Removing the field later, as documented,
would have broken SSO sign-in.

The exchange now answers { success: true, token } and the login hook
signs in when the response is OK and success is true, so it never reads
token. The field stays for API clients and is now on the deprecation
list in the API docs, the controller comments, server/CLAUDE.md and the
wiki (Security-Hardening, OIDC-SSO). Adding a field to the body breaks
no existing client.
2026-10-08 18:44:00 +02:00
Maurice 1ad6ae0ebc fix(auth): keep demo visitors out of each other's sessions
On a demo instance every visitor signs in as the one shared demo
account, so its sessions are other people's browsers. The session
routes had no demo check, unlike the password, MFA and account
changes, so any visitor could read the User-Agent of everyone else
signed in and sign them all out with revoke-others.

For the shared demo account in demo mode, GET /api/auth/sessions now
lists only the session the request came with, and DELETE
/api/auth/sessions/:id and POST /api/auth/sessions/revoke-others answer
403 "Sessions cannot be ended in demo mode." without ending anything.
Logout still ends the visitor's own session. Other accounts on a demo
instance keep the full routes. No MCP tool covers sessions, so there is
no parallel tool to change.

The demo sign-in now records the device's User-Agent on its session
like every other sign-in, which the session list needs to tell the
visitor's own entry apart.
2026-10-08 18:43:42 +02:00
Maurice 9aab9e7513 feat(auth): track sign-in sessions so a session can end before it expires
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.
2026-10-08 18:43:06 +02:00
Maurice 9cebadf67c fix(memories): save the Immich connection and auto_upload in one transaction
PUT /api/integrations/memories/immich/settings saved the connection through
one service call and then wrote auto_upload through a second one from
the controller, each committed on its own. A failure between them left
a new connection with the old upload choice.

saveImmichSettings takes the auto_upload flag and writes it with the
connection (or the disconnect) inside one transaction, after the SSRF
check that does the DNS lookup. An absent or non-boolean flag still
keeps the stored choice, a refused URL still writes nothing, and the
response is unchanged. The controller's write sequence leaves the
lint:tx baseline, and the MemoriesService facade loses the
immichSetAutoUpload pass-through it no longer needs. No MCP tool writes
these settings.
2026-10-08 18:42:10 +02:00
Maurice f40d92a69e fix(trips): write the budget currency rebase with the trip row
TripsService.update rebased the budget onto the new currency in its own
transaction and only then wrote the trip row in a second one. When the
day grid rebuild, the booking resync or the leave shift threw after the
rebase had committed, budget items and settlements stayed pinned and
re-frozen against a currency the trip never took (#1543).

rebaseTripCurrency is split into prepareCurrencyRebase, which fetches
the rates before any transaction opens, and applyCurrencyRebase, which
reads the outgoing currency and writes the rebase. update() fetches
first and hands the plan to updateTrip, which applies it inside the
transaction that writes the row, the leave shift and the day grid.
updateTrip called without a plan (the plugin RPC host) still never
rebases, and rebaseTripCurrency keeps working as before.

The updateTrip docstring no longer claims the row is written outside
the day regeneration transaction. TripsService.update leaves the lint:tx
baseline, and the size baselines shrink with the files.
2026-10-08 18:42:10 +02:00
Maurice b3a085df3f refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
2026-10-08 18:41:01 +02:00
Maurice 0aa5b36661 fix(backup): back up and restore through an injected database port
Backup, restore and the demo reset each handled the SQLite file
themselves: backup.impl closed and reinitialized the connection, built
MaintenanceRepository with new, and assumed the database was
data/travel.db. That tied the whole chain to one local file and hid two
bugs.

They now go through DATABASE_BACKUP, a port declared in the shared
database kernel (snapshot, verify, keep a copy before a restore, replace).
SqliteDatabaseBackup implements it with what the code did before:
VACUUM INTO for a consistent copy, and a close, copy, sidecar drop,
rename and reopen through DatabaseLifecycle for a restore. BackupService
injects it and hands it to backup.impl. The demo reset and the baseline
save use the same port; the first baseline is saved by DemoResetJob once
the app is up, because the boot-time seed runs before anything can be
injected. Another engine can plug in a logical dump behind the port.

Fixes on the way:

- The backup and the restore ignored TREK_DB_FILE. The backup looked for
  data/travel.db and, with the database elsewhere, silently left it out
  of the archive; the restore wrote data/travel.db and never replaced
  the live database. Both now use DatabaseLifecycle.file, the path the
  connection runs on, and the pre-restore copy sits next to it.
- A failed VACUUM INTO fell back to archiving the live WAL-mode file
  without a word, the torn copy the snapshot exists to prevent. The
  backup now fails: nothing is archived or committed, the existing
  backups are untouched, a partial snapshot is removed, and the log says
  why. A missing database file and a failed checkpoint are logged too.
2026-10-08 18:38:28 +02:00
Maurice c956fe92ba Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and
DatabaseService the Tours server side was built on are gone. Behaviour
is unchanged; persistence moves onto dev's architecture:

- one MikroORM migration for tour_types, tours and tour_waypoints,
  idempotent so instances that ran the old positional steps keep
  their data; the hike tour type is seeded inside it, the addon row
  in the AddonSeeder
- entities and repositories for the three tables, tours domain on
  repositories and UnitOfWork
- tour facet joins in places, assignments and days re-applied on the
  repository code

Fixes found while porting:

- GPX import checks addon, trip access and permission in the handler
  instead of guards, so a refused upload gets a 403/404 instead of a
  connection reset
- copying a trip copies its tours and waypoints
- a tour cannot be moved onto a day that already has it
- updateTour checks existence inside its transaction

Adds tours e2e tests and rewrites the tours unit tests onto the
migrated test database.
2026-10-06 22:43:09 +02:00