Commit Graph

596 Commits

Author SHA1 Message Date
Maurice 2860148df3 test(server): seed the plugin discovery, permission, feed and stay specs through the factories
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.
2026-10-08 18:46:48 +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 8b2e7baacd test(server): seed the trip read model, share token and audit log specs through the factories
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.
2026-10-08 18:46:48 +02:00
Maurice 2529255559 test(server): seed the websocket, push, api key and day specs through the factories
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.
2026-10-08 18:46:48 +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 aec925cffb test(server): seed the journey domain spec through the factories
The journey domain suite now writes its photos, entries, contributors,
trip links, plan edits and place changes and reads back what a call left
through the ORM factories. Three small readers keep the shape a
statement's .get()/.all() handed back: one() answers undefined when no
row matches, pickOne()/pickAll() return just the named columns, and a
count stays { n }, so every toBeDefined, toBeUndefined and toEqual keeps
its meaning. The helpers that wrote or read rows (photoOnEntry, photoOn,
insertEntry, orderOf, daysOf, skeletonFor, skeletonsFor, skeletonsOf,
withGeometry) are async and awaited where they are used, and the M5d
parity seed binds its values in the old column order through columnsOf.

Kept raw and marked: the legacy statements of both repository parity
blocks (JG8, JG14, JG17, JG18, JG34 and the LEG set) and the unguarded
delete the JG119 mutation proof measures the guard against.
2026-10-08 18:46:39 +02:00
Maurice 2a51abaeb8 test(server): seed the budget service db spec through the factories
The DB-backed budget service suite now sets trip currencies, hangs
expenses on bookings, writes places, bookings, trip files, file links
and category order, and reads back items, members, payers, settlements
and links through the ORM factories. Its row readers (paidFlag,
personsOf, memberCount, itemRow, placeRow, settlementRow, totalOf,
rateOf, metadataOf, linksOf) and seeders (tripWithBooking, seedAudTrip)
are async now and awaited where they are read; projected comparisons map
the same keys off the found row.

The database mock drops its dead trip access stand-ins. Kept raw and
marked: the legacy BG10 to BG13 and settlement statements the repository
reads are held to, and the currency switch BUDGET-SVC-DB-066 makes from
inside the stubbed rate fetch.
2026-10-08 18:46:39 +02:00
Maurice 9755c51c35 test(server): seed the days service spec through the factories
The days service suite writes bookings, legs, stays, vias, boundaries
and day edits through the ORM factories and reads them back with
findRow/findRows/countRows. The day, booking and stay readers the cases
compare against (dayRows, reservationRow, staySpan, stopsOn, viasOn,
boundaries) are async now and awaited where they are read; a reader of a
row that is gone fails the case as the old cast did.

The database mock drops its getPlaceWithTags, canAccessTrip and isOwner
stand-ins, which the module stopped exporting when those reads moved
onto the repositories.
2026-10-08 18:46:38 +02:00
Maurice b09f48c4d7 test(server): seed the packing service spec through the factories
The packing service suite writes its items, bags and templates and reads
back what a call left through the ORM factories. Template items are read
category by category instead of through a join, and the trip access
stand-ins the database mock still carried go, as the module no longer
exports them.

The repository parity block keeps its legacy statements raw, marked: the
VISIBLE_TO_ACTOR fragment, the assignee join, the correlated item count,
the category assignees and the export read are the oracles the converted
reads are held to.
2026-10-08 18:46:38 +02:00
Maurice 58cdaf828c test(server): seed the days integration, OIDC and share service specs through the factories
The days integration suite, the OIDC service suite and the share service
suite write their fixtures and read back their effects through the ORM
factories: invite tokens through makeInviteToken, user column edits
through updateRows, bookings, stays, packing items and messages through
insertRow on their entities. Two more database mocks drop their dead
canAccessTrip/isOwner stand-ins.

The OIDC identity-linking cases keep their raw SELECT * of the user row,
marked: they assert that no column of the table moved, including any the
entity might not map.
2026-10-08 18:46:38 +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 2cf853e504 test(server): seed the atlas and notes tools, day removal, doc sync config and plugin runtime specs through the factories
The atlas and notes MCP tool suites, day removal, the doc sync config
service and the plugin runtime conversion suite build their fixtures and
read back what a call left through the ORM factories. Helpers that used
to answer synchronously off the handle (dayRows, range, the reservation
and boundary readers) are async now and every caller awaits them; a
reader that answered undefined for a gone row still does, so the
toBeUndefined/toBeDefined checks keep their meaning.

The one statement left raw rewinds sqlite_sequence, the engine's own
autoincrement table, which no entity maps; it is marked.
2026-10-08 18:46:37 +02:00
Maurice 2b41a55022 test(server): seed the reminder, roster, Dawarich and plugin specs through the factories
Reminder jobs, user profile, trip members, Dawarich suggestions, the doc
sync controller, the plugin registry and the plugin repository parity
suite now write and read their rows through the ORM factories. The doc
sync controller's itemsForTrip stand-in answers through the two
repository reads DocSyncService makes instead of a copy of their SQL, and
one more database mock drops its dead canAccessTrip/isOwner stand-ins.

What stays raw is marked: the legacy statements the parity cases are
held to, and the places stub in the Dawarich suggestions suite, which
runs inside the service's open transaction and has to write on the
connection that transaction holds.
2026-10-08 18:46:26 +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 feda6ffdbc test(trips): seed the whole-copy parity reservation through insertRow
The TRIP-SVC-069 reservation seed was a raw INSERT carrying a
test-sql-allow marker, justified as reproducing the legacy raw binding of
a number into the TEXT accommodation_id. legacyBoundIntegerText already
writes that exact "<id>.0" shape and the sibling copy tests seed the same
column with it through insertRow, so nothing about this statement needs
to stay raw. The seed now goes through the factory with the same column
values, and the assertion still pins the legacy shape.
2026-10-08 18:46:06 +02:00
Maurice e4d0b40404 test(server): seed the trip and vacay service specs through the repositories
The two largest service suites seeded and inspected rows with raw
statements. They now write through the ORM factories and read through
small local readers that return a row the way the SELECT did (only the
selected columns, undefined when there is none), so every assertion keeps
its exact meaning. The trips database mock loses its dead canAccessTrip,
isOwner and getPlaceWithTags stubs.

Raw statements stay, marked, where they are the subject: the legacy day
grid rebuild that the fuzz case runs as its oracle inside a synchronous
transaction it rolls back, the reservation seed that reproduces the
legacy REAL binding into the TEXT stay link, and the legacy entry read
model the vacay suite compares against.
2026-10-08 18:46:06 +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 01f0bf3ef9 test(server): seed the larger service specs through the repositories
The settings, notification, road trip plan, collection and accommodation
service suites wrote their fixtures and read back their results with raw
statements against the shared test handle. They now seed and inspect
through the ORM factories and readers on the suite's shared test ORM,
with small local helpers where a suite repeats the same read.

The collection and accommodation database mocks drop their dead
canAccessTrip, isOwner and getPlaceWithTags stubs: db/database exports
none of them since the repositories took them over.

Raw statements stay, marked, where they are the subject: the legacy
read model oracle in the collections suite, the SELECT * that proves a
saved place has no itinerary columns, and the member count taken
synchronously while the delete broadcast fires.
2026-10-08 18:45:57 +02:00
Maurice 8eaeed07a8 test(server): seed the smaller service specs through the repositories
The service suites for mail, notification channels and transports,
idempotency, invites, tokens, OAuth, push subscriptions, the version
check, budget settlement maths, addons, place shadow picks, tour GPX
import, the place region repair, journey stats, day notes and storage
jobs seeded and inspected their rows with raw statements. They now go
through the ORM factories and readers over the suite's shared test ORM.

The database mocks of day notes and OAuth also lose their dead
canAccessTrip and isOwner stubs: db/database exports neither since the
repositories took them over.

Raw statements stay, marked, where they are the subject: the backup
file the boot restore reads before any ORM exists, the full raw row a
day note is checked against, sqlite_master, the synchronous probe that
counts tours while the GPX import may still hold its transaction, and
the edits the region repair case lands mid-pass, past the ORM's
connection queue.
2026-10-08 18:45:56 +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 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 b872bb48a6 refactor(auth): check sessions through SessionsService, not its repository
The sessions domain owns user_sessions, yet AuthService injected
UserSessionsRepository next to SessionsService and AuthModule registered
the entity in its own forFeature, only so verifyJwtToken could hand the
repository to verifyJwtAndLoadUser.

verifyJwtAndLoadUser now takes a SessionLookup, the two repository
methods the session check uses (findActive, touchLastSeen).
SessionsService answers both, so AuthService passes itself through the
sessions domain's API and drops the repository and the forFeature entry.
The guards keep handing in the repository from their own entity
manager, which satisfies the same type. auth.service.ts lost lines, so
its size baseline goes down with it.
2026-10-08 18:43:50 +02:00
Maurice ac89895978 fix(sessions): renew a token from before tracking into one session
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.
2026-10-08 18:43:42 +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 474beb0dfb fix(sessions): carry the signed-in sessions across a demo reset and a restore
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.
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 094e0b28f8 fix(booking-import): write an imported venue with its booking in one transaction
confirm() created the venue's place row in its own transaction and
broadcast place:created before the booking and its cost were written in
a second one. When that second write failed, the item was skipped but
the trip kept a place no booking pointed at, and clients had already
been told about it.

Per item, the venue and endpoint geocoding, the day lookups and the
cost's frozen rate still run first, outside any transaction. The place
row, the booking and its linked cost then go into one transaction
through the new ReservationsService.createWithCostInTx, the core that
createWithCost itself now wraps. place:created, reservation:created,
accommodation:created, the stay mirror and the cost events are sent
only after the commit. Each item keeps its own transaction, so a
failing item still leaves the others imported; confirm() is tagged
@txIndependent for that and leaves the lint:tx baseline.
2026-10-08 18:42:10 +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 615a0b14f5 fix(reservations): roll a booking write back when its linked cost fails
createWithCost and updateWithCost run the booking and its cost in one
transaction, but the cost sync still caught and logged its own errors
inside it. A price that could not be written therefore committed the
booking edit and left the linked expense on its old figure; only the
category re-file path actually rolled back.

The sync no longer swallows a failing cost write. Both WithCost methods
are its only callers, so the error now reaches their transaction and
the booking and its cost commit or fail together. A booking import
item whose cost cannot be written is skipped like any other failing
item instead of landing without its cost.
2026-10-08 18:42:10 +02:00
Maurice 2fb699189e test(journey): add gallery and entry photos concurrently
uploadGalleryPhotos and addPhoto read MAX(sort_order) inside the transaction
that inserts, but the only gallery test ran its uploads one after another and
would pass without that. These cases run several uploads, and several photos
added to one entry, at once and expect consecutive positions in the gallery
and on the entry, as JOURNEY-SVC-030b does for entries.
2026-10-08 18:42:09 +02:00
Maurice c7feaf0a65 fix(reservations): link a cost created on a booking update on its insert
syncBudgetOnUpdate created the expense and then set its reservation_id in a
second statement, inside a try that logs and swallows the failure. Running
inside updateWithCost, a failed back-link committed an expense that was not
linked to its booking. The expense is now created through
linkBudgetItemToReservation, which writes the link on the insert, the same
way syncBudgetOnCreate does.
2026-10-08 18:42:09 +02:00
Maurice 36a0d1eaba fix(auth): erase plugin user data in the transaction that deletes the user
erasePluginUserData deleted the host-side plugin rows and then queued the
plugin erasures as separate commits, and deleteGuest ran it before its own
transaction. When the guest delete failed, the guest kept its account but had
lost its plugin rows, with erasures queued for a user that still exists. The
erasure is now one transaction, and deleteGuest runs it inside the same
transaction as the budget re-split and the user delete, so it becomes a
savepoint of that write. Only DB writes and a synchronous readdir run inside.
2026-10-08 18:42:09 +02:00
Maurice b7e13e4af6 fix(booking-import): write an imported booking with its cost in one transaction
Confirming an import created the booking, then froze the FX rate and created
the linked cost in a second commit, so a failed cost write left the booking
without its expense. REST, MCP and the plugin RPC already go through
ReservationsService.createWithCost. The import now builds the cost entry and
freezes its rate first, outside the write since that can fetch rates, then
calls createWithCost and announces the cost after the commit. A rate that
cannot be frozen keeps the booking without its cost, as before. The place a
venue becomes still commits on its own, with endpoint geocoding between it
and the booking, so confirm stays in the lint:tx baseline at 4.
2026-10-08 18:41:57 +02:00
Maurice 9dd2e1b983 fix(server): take MAX+1 positions inside the transaction that inserts
Days, todo items, packing items and bags, packing template categories and
items, collections, collection labels and road-trip vias took their next
position as MAX(...) + 1 in one statement and inserted in the next, with
any other request free to run in between: two adds at once read the same
MAX and landed on the same position (the road-trip test pinned exactly
that duplicate). The read now sits in the transaction that inserts, and
the transaction holds the connection, so the second add waits and takes
the next position. A collection label's limit and name check move into
the same transaction, so two labels of one name added at once end in one
label and a 409.

Each site gets a test that adds several rows at once and reads back one
position each.
2026-10-08 18:41:32 +02:00
Maurice e015e5e5e8 fix(server): run the remaining multi-statement service writes in one transaction
A sweep of the services for write sequences that committed step by step,
each one leaving half a change behind when the next statement failed:

- accommodations: the linked bookings' times moved after the stay's own
  transaction had committed
- airports: the boot backfill wrote a flight's two endpoints one at a
  time, and a flight left with only the first was never picked up again;
  each flight is its own transaction now
- auth: the admin settings form and a password reset (retire the old
  link, issue the new one) each land whole
- oauth: a refresh revoked the old pair before issuing the new one, so a
  failure in between logged the client out; rotating a client secret and
  revoking its tokens go together too
- oidc: the identity link, the role from the claims and the avatar of a
  returning user are one write
- addons, notification preferences: a settings save lands whole
- places: create with its tags, update, and the GPX/KML imports with
  their track colours
- trips: the trip row, the shifted leave entries and the rebuilt day grid
- vacay: a first visit provisions the plan in one transaction with a
  second look against a concurrent first visit; updatePlan fetches the
  public holidays between two transactions instead of inside its writes
- memories: a photo and its trip link, and the deletes with the orphan
  sweep behind them

AddonsService, AirportsService and OauthService take the UnitOfWork for
this. Network I/O stays outside every transaction. Comments that only
narrated the migration plan make room under the lint:size entries, which
drop with them.
2026-10-08 18:41:32 +02:00
Maurice f6076328fe fix(journey): create a journey with its owner, trips and photos in one transaction
createJourney inserted the journey, then its owner row, then linked each
trip with its skeleton entries and set the cover, every step committed on
its own: a failure halfway left a journey nobody owned. It now runs as
one transaction and broadcasts the linked trips after the commit.
addTripToJourney links inside a transaction the same way.

The photo paths wrapped only the gallery insert and left the entry link
and the skeleton promotion outside; the photo row, the gallery row, the
link and the promotion are one write now. removeTripFromJourney,
updateEntry, updatePhoto, setPhotoProvider, the photo deletes and the
place hooks write in one transaction each.

createEntry and uploadGalleryPhotos read MAX(sort_order) inside the
transaction that inserts, so two entries or uploads at once take one
position each. reconcileTripSkeletons and the photo capture helpers are
tagged as deliberately split for the transaction gate that follows.
2026-10-08 18:41:06 +02:00
Maurice 5cbd9e9d36 fix(reservations): write a booking and its linked cost in one transaction
A booking's create and update ran their own transaction, and the linked
cost sync ran afterwards from the REST controller and the plugin RPC
handler, so a failing cost write left the booking edited and its expense
on the old category or price. MCP linked the cost in a second call of its
own the same way.

ReservationsService.createWithCost and updateWithCost now run the
booking write and the cost sync in one transaction and hand the cost
events back, so REST, MCP and the plugin RPC call the same method and
broadcast only after the commit, in the order they did before. The cost
sync keeps swallowing its own failures the way the REST route always
has, so a cost that cannot be written still leaves the booking; MCP now
behaves like REST there instead of failing the tool after the booking
was already saved.

The service sheds its inventory markers (RS1..RS54, plan history) to
stay under its lint:size entry.
2026-10-08 18:41:06 +02:00
Maurice aa8d9b4037 refactor(server): inject the transit base URL and the kitinerary paths as config tokens
The ownership rule says a boot-stable value that only Nest providers read
belongs to a registerAs token, yet TransitService froze TRANSIT_API_URL in a
module-top readEnv() const and KitineraryExtractorService read
KITINERARY_EXTRACTOR_PATH and PATH through readEnv() in its probe. Both now
come from tokens: transitConfig and kitineraryConfig, derived in
boot-derive.ts and dropped from deriveIntegrations, so readEnv() no longer
reads those variables at all. TransitModule and KitineraryExtractorModule
import AppConfigModule, which keeps them buildable on their own in tests.

Behaviour is unchanged: the same defaults, the same trailing-slash strip and
PATH split, now frozen per built app instead of at import. The app-config
README names each boot-stable variable that stays on readEnv() together with
the reader the container cannot inject into.
2026-10-08 18:41:01 +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 fa0e6a3eae refactor(server): take the storage roots from DataPathsService
The data-directory change said Nest classes inject DataPathsService, but
only AdminService did. StorageRegistryService still read the built-in
uploads and backups roots and the scratch dir from module constants
frozen at import, and StorageJobsService read the scratch dir the same
way.

The registry now takes DataPathsService through its constructor (a
hand-built one resolves the same layout), and the jobs service asks the
registry for its temp dir. The plain helper modules keep calling
resolveDataPaths() because they run without a container or are shared
with code that does: config.ts, the database path, index.ts, the logger,
the backup functions and the plugin trees, which the out-of-process
supervisor reads too. The app-config README now lists both sides, and
the nest README, server/CLAUDE.md and the provider's own comment say the
same.
2026-10-08 18:40:52 +02:00
Maurice a72497b3c4 fix(server): trace a cron tick lost to another process as skipped
A tick whose lease another process held returned from runLeased without
running, and traceEntry still wrote `cron <name> ok`, so both processes
logged the tick as run and the per-tick line could not tell which
replica did the work. runLeased now says whether the tick ran, and
traceEntry takes a `skipped` option that logs such a call at debug as
`cron <name> skipped <ms>: lease held by another process`.

Taking the lease is two statements (make sure the row exists, then the
conditional UPDATE that decides the race), so it now runs inside
UnitOfWork.transactional like every other multi-statement write.
2026-10-08 18:40:37 +02:00
Maurice 06dfdc6943 refactor(server): put process-local state behind injectable stores
Rate-limit counters, OAuth pending codes, OIDC login states and codes,
the permissions cache and the WebSocket rooms were Maps on module or
instance scope. That is right while TREK runs as one process, but nothing
could be swapped without editing every consumer.

Each now sits behind a port with an in-memory implementation that keeps
the old behaviour exactly: RateLimitStore, PendingCodeStore,
OidcFlowStore, PermissionsCacheStore and RoomRegistry. The owning module
provides the implementation with the same lifetime the state had
(process-wide for the pending codes, the permissions cache and the rooms,
per instance for the rate limits and the OIDC flows), the consumers take
it through their constructor, and a hand-built consumer falls back to the
same in-memory store, so tests and the free-function paths (the backup
restore's cache flush, the broadcasts) see what they saw before. A store
shared between processes can replace a provider later without touching
the services.
2026-10-08 18:40:37 +02:00
Maurice 5cb87a4df5 feat(server): add last-resort error handlers, a draining readiness probe and proxy keep-alive
Three small gaps in how the process behaves at its edges:

- An unhandled rejection or uncaught exception crashed the process with
  the stack on stderr only. index.ts now logs it to trek.log and runs the
  same orderly shutdown a SIGTERM gets, exiting with 1; a second fatal
  error during that shutdown exits at once.
- /api/health/ready kept answering 200 while a SIGTERM was draining the
  process, so an orchestrator sent traffic for another probe period. A
  shutdown now marks the new ReadinessService first (and Nest's own
  shutdown hook marks it too), and the probe answers 503 from then on.
- Node closes an idle keep-alive connection after 5 seconds while reverse
  proxies keep upstream connections for about a minute, which shows up as
  intermittent 502s. The server now keeps them for 65 seconds, configurable
  through HTTP_KEEP_ALIVE_TIMEOUT_MS (owned by the httpConfig token), with
  the headers timeout one second above.
2026-10-08 18:40:37 +02:00
Maurice c5bc6798a9 feat(server): take a database lease before a cron tick runs
waitForCompletion only kept a tick from overlapping itself inside one
process. Two processes on the same database (a rolling update's overlap,
a second replica, a dev container pointed at the same file) both ran
every job: two auto-backups, double syncs and retention sweeps, and
reminder runs racing each other.

A new migration adds scheduler_leases, and CronRegistrarService now runs
every registered tick through SchedulerLeasesRepository.acquire: one
conditional UPDATE moves the job's row to this process when it is free
(no holder, a lapsed holder, or this process already), so exactly one
process wins. A running tick renews its lease every 20 seconds, a crashed
holder frees the job after a minute, and a finished tick keeps the lease
for 30 seconds so a peer whose timer fires late does not run the same
tick again. The per-row reminder claims stay as they are; jobs need no
change of their own.
2026-10-08 18:40:15 +02:00