Commit Graph

22 Commits

Author SHA1 Message Date
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 e81efcbc57 refactor(auth): auth, passkey, profile, token and admin services refuse with DomainError instead of returning error envelopes 2026-10-10 20:16:05 +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 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 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
jubnl de8caa81a2 Feat/orm : mikroorm (#2416)
* base entities

* update deps

* fix date as string

* rename entities

* migrations + seeders

* feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text

* feat(db): discover entities from an explicit list instead of a glob

* test(db): createTestOrm binds an ORM to a suite's own SQLite handle

* feat(db): dialect helper is the one place SQL functions are spelled

* test(db): dialect helper refuses malformed column references and day counts

* feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips)

* fix(db): repositories mirror the statement's column set and guard their row types

* feat(db): request-context and unit-of-work helpers for the ORM, with concurrency tests

* test(server): integration suites boot on the migrated schema snapshot

* test(server): snapshot harness keeps the legacy id semantics

* fix(test): route MikroORM's dynamic imports through vitest from the setup file, not the ORM config

* test(db): coverage ratchet entries for the ORM foundation

* test(server): websocket suites boot on the migrated schema snapshot

* test(server): coverage's uncovered-file pass goes through the SWC transform

* fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer

* test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema

* chore(server): promise safety net and sweep tooling for the async migration

* refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork

* fix(server): observe every promise the async sweep left unobserved (timer, handshake, plugin-runtime test)

* test(doc-sync): unit suites await the methods the async sweep made async

* refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork

* chore(server): lint rule refusing a Promise used as a plain value, and the misses it found

* test(server): suites await the methods Task 1's forced edits made async

* refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork

* test(oauth): controller suite awaits the async service

* chore(server): db-call-graph --unawaited lists calls to async methods whose result is used without await

* refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork

* refactor(plugins): async DB-touching methods; transactions through UnitOfWork

* chore(server): db-call-graph --sync stops its transitive walk at an async callee

* chore(plugins): drop gate-only asyncs; observe the scheduler's rejection; async event-sink stub

* refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork

* refactor(auth, files): async DB-touching methods; transactions through UnitOfWork

* test(server): suites await the methods earlier sweep groups made async

* refactor(tokens): async DB-touching methods; the auth token handlers await them

* test(files): upload filters fail closed on an excluded or unresolvable type

* refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork

* refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork

* chore(server): lint rule also refuses a Promise handed to expect() without resolves/rejects

* refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork

* fix(vacay): type the loop accumulators the async sweep introduced

* refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork

* fix(server): the journey hooks are awaited by their callers, not detached

* refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork

* refactor(trips): rewrite AccommodationsService.createAccommodation .bind alias

Recipe R4 trap named in the Task 12 brief: a .bind alias of an
AssignmentsService/AccommodationsService method is typed any and hides a
missing await from tsc and the promise lint rules. createAccommodation
belongs to this task's group, so rewritten as an arrow forwarding to the
method instead of a bound reference.

* refactor(assignments): drop the unused Trip type alias

* refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork

* refactor(roadtrip, reservations): return await on transactional; document the single-flight promises

* refactor(trip-read-model, trips, feeds): async DB-touching methods; transactions through UnitOfWork

* refactor(server): remove DatabaseService.transaction — every transaction runs through UnitOfWork

* refactor(trips, feeds): drop added return annotations; no double await on the feed guard

* fix(server): phase 1 review follow-ups — awaited returns, logged upload-filter rejections, covered handshake/notice branches, call-graph gate in CI, dead test imports

* fix(server): upload filters answer multer once on a throwing callback; positive call-graph gate test

* test(db): entity metadata must match the migrated schema; fix the four drifts it found

* feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings)

* test(db): parity gate also holds declared pivot entities; per-field column types; missing-set ratchets both ways

* test(db): numeric ids for the primary-relation spike

* feat(db): re-runnable entity generator encoding the snake_case/DbTimestampType rules

* fix(db): generator guard rails — validation cannot mask hidden()/index loss; typed JSON; loud failures

* fix(db): generator keeps NOT NULL boolean defaults; fails if a JSON interface import survives

* refactor(db): every entity in the snake_case shape; document-sync entities added; one repository per entity

* fix(db): no NaN class-field initialisers; entity generator --check gate in CI; parity normaliser keeps literal case

* chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked

* feat(db): AppSettings and Users repositories; forFeature wiring pattern

* refactor(permissions): permission flags through AppSettingsRepository

* fix(server): boot-time settings read runs inside an explicit ORM request context

* refactor(audit): audit log writes through AuditLogRepository

* fix(db): PK reads refresh past the identity map; typed owned-lookup generics; e2e ORM module mirrors production config

* docs(db): owned-lookup call-site convention; refresh side effect; e2e ORM module spread notes

* fix(audit): audit rows are native inserts, not a unit-of-work flush; closed test ORMs

* fix(server): non-HTTP entrypoints run inside an ORM request context; permission loads fail closed on context errors

* refactor(categories, tags): repositories replace raw SQL

* refactor(addons): addon catalog and photo providers through repositories

* fix(db): generator captures inline UNIQUE constraints

SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_
entry before entity-generator ever sees it, so a table-level
UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX
(settings' UNIQUE(user_id, key) among ~40 others) never reached the
generated entity's uniques: metadata. Adds a general
RULE9_addImplicitUniqueConstraints (skips a column set identical to
the table's own primary key — AppSettings.key/Addons.id noise) fed by
a PRAGMA index_list/index_info scan, and regenerates every entity.

* chore(addons): well-known gate wraps only the addon read in a request context; drop unused test factories

* fix(platform): well-known gate tolerates a synchronous addon read from the context helper

* fix(db): PK lookups inside patch refresh past the identity map; corrected identity-map notes

* refactor(settings): user and instance settings through repositories

SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces
the per-user settings table's raw SQL; SettingsService drops DatabaseService
entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/
resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead
of a raw connection.

An earlier version resolved those two repositories itself from the active
MikroORM request context, so none of its six callers needed to change. That
worked in production and e2e (always inside a real request) but broke
service construction in plain unit tests across every calling domain
(addons, auth x2, maps, transit, unsplash - 185 failures on a full run),
since none of them wrap a request context. Each of those six services now
injects its own AppSettingsRepository/UsersRepository and passes them
through explicitly, matching how every other converted repository in this
migration is consumed; their own SQL is untouched (outside Plan 3a).

* test(server): harness constructors follow the Task 5 repository signatures; one test ORM per handle in the plugin host

* fix(db): generator names relation properties in captured UNIQUE constraints

* fix(settings): drop dead wiring and the speculative deleteForUser; strict row assertions

* fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed

* fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned

* feat(db): generated entities declare their repository type; test casts dropped

* fix(server): plan 3a re-review 2 follow-ups — absent-ORM boot branch pinned; airports boot hook never rejects; BOOT-SWEEP-001 asserts the seven routes; duplicate comment removed

* feat(server): pre-init photo route and WS handshake run in a request context; InviteTokensRepository

* refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed

* refactor(tokens): MCP and API tokens through McpTokensRepository

* refactor(auth): passkeys and registration invites through repositories

* fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned

* fix(server): non-numeric route ids answer the legacy not-found, never NaN (tokens, categories, tags); mint path back to two statements; identity-map regression pinned

* refactor(oauth): OAuth server tables through repositories

OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.

* fix(auth): non-numeric passkey and invite ids answer the legacy 404, never NaN; invite join type pinned

* fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped

* refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default

* refactor(auth): AuthService and account cleanup through repositories

* refactor(oidc): OIDC identities and settings through repositories

* fix(auth): invite validation keeps expires_at null on the wire; password transaction scopes and reset-token burn pinned

* fix(server): plan 3b close-out — case folding on both sides for identifier lookups (non-ASCII login regression); coverage ratchet restored; repository scaffold and instanceof ratchet on TrekRepository; D-shape tests load-bearing

* refactor(server): trip-access primitives async at every caller

* refactor(trips): trip access through TripsRepository; demo seed in a request context; dialect helpers for the trips cluster

* refactor(days): days and day notes through repositories

* refactor(assignments): day assignments through repositories

* refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories

* refactor(trip-members): membership, guests and the trip read model through repositories

* refactor(places): places, tags, ratings and place images through repositories

* fix(server): place-shadow source counts ordered in SQL; identity-map ratchets load-bearing; photo-cache race pinned

* refactor(places): import paths through repositories

* refactor(trips): trips through repositories

* fix(server): trip ids parsed once at the gate for places and assignments; NUL-safe value quoting on the SQLite platform; places parity tests completed

* refactor(trips): trip copy through repositories for the owned tables

* fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate

Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.

PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.

DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.

Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.

Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.

* test(server): plan 3c close-out — copy-source full-row parity, Kysely rollback proofs, identity-map relabels, trips e2e on the snapshot DB

* fix(server): plan 3c close-out — days create/insert answer the guard 404 for non-canonical trip ids instead of 500; exact settlement and cross-request yield ratchets

* feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets

* refactor(roadtrip): roadtrip tables through repositories

* refactor(reservations): reservations, endpoints and travelers through repositories

* refactor(feeds): ICS feed tokens and anonymous feeds through repositories

* refactor(accommodations): stays through repositories inside the existing transactions

* test(server): plan 3d — MCP test helper constructors follow the accommodations and days services

* refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables

* refactor(calendar): calendar reads and reservation visibility through one predicate

* feat(server): plan 3e groundwork — packing visibility predicate, memories access-gate audit

* fix(server): plan 3d close-out — legacy text shape for stored accommodation ids restores the date-change restamp; reservation ids parsed once at the gate; parity tests for the reservation repositories

* refactor(photos): trek photos, memories access and cache sweeps through repositories

* refactor(files): trip files and file links through repositories, with transactions added

* test(server): plan 3e — hand-built FilesService, FilesRpc and AllowedFileTypesService calls follow the repository constructors

* refactor(memories): unified, immich and synology photo sync through repositories

* refactor(collab): notes, polls, messages and links through repositories

* refactor(budget): budget items, members, payers and settlements through repositories

* refactor(todo): todo items through repositories

* refactor(packing): packing items, bags and templates through repositories

* feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins

* refactor(school-holidays): country and region catalog through repositories

* refactor(notifications): in-app notifications and channel preferences through repositories

* refactor(notifications): reminder crons, mailer, ntfy and webhook transports through repositories

* fix(server): plan 3e close-out — repository registrations restore the boot; packing assignee and bag updates parse and no-op as legacy; coverage and scoping ratchets for the budget, packing and collab repositories

* refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories

* refactor(system-notices): notice dismissals through a Nest provider and repository

* refactor(atlas): visited countries, regions and bucket list through repositories

* feat(server): plan 3g groundwork — GALLERY_CHRONOLOGICAL_ORDER Kysely ordering, unixEpochToIsoKysely helper, composite-PK DDL pins, JB5/R7 confirmations

* refactor(journey): journey and trip access control, CRUD and the trip-sync engine through repositories

* feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep

* fix(server): plan 3f close-out — ntfy operator-scope test on the live send path; vacay date-offset and shift-order parity; todo reminder window; coverage and parity ratchets for the atlas and vacay repositories

* refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories

* refactor(journey): public share links, TREK Studio books and the journal facades through repositories

* refactor(collections): access model, hydration, export/import and list CRUD through repositories

* refactor(server): journey-table survivors in trips, photos, memories, auth-cleanup and plugin-contributions through repositories

* refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories

* refactor(collections): saved places, copy-to-trip, labels and invitations through repositories

* refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories

* refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories

* test(integrations): add AirTrail saveSettings tx-asymmetry ratchet (Plan 3h Task 4 addendum)

* fix(server): plan 3g close-out — transactional book first-save, skeleton reconcile and share-link create; unknown-key entry patches are no-ops again; parity and authorization pins for the journey repositories

* feat(server): plan 3i groundwork — AuditLogRepository's reserved methods, the demo connection-swap spike, repository-fitness confirmations

* feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness

* refactor(storage): backend/category config and usage stats through AppSettingsRepository; fix the seed-file test race and the shared-replica migration-flip gap

* refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories

* feat(server): plan 3j — the withCamelCase utility, unwired, pending a contract decision

* refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories

* test(admin): pin createUser's pre-existing non-transactional uniqueness-check race (AD2-4)

* refactor(plugins): the trip-write role gate through a repository, in isolation

* refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService

* fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations

* refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories

* fix(server): plan 3i close-out — DemoModule imported where its service is consumed (e2e restored); admin addons list selects the provider relation; saveDemoBaseline awaited; backup tests on the maintenance repository

* refactor(plugins): outbound OAuth tokens and per-user plugin settings through repositories

* refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories

* refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories

* test(server): plan 4 — collapse mcp tool test harnesses onto the ORM snapshot

* test(server): plan 4 — collapse nest service test harnesses onto the ORM snapshot

* fix(server): plan 3j close-out — serialized audit-chain appends, atomic metadata and scheduler limits, rollback and race proofs for the plugin repositories

* refactor(server): plan 4 — convert the program's orphaned raw-SQL callers

* fix(server): plan 4 — un-skip PARITY-009 (FK deleteRule and index-name parity)

* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (trip-domain services)

* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces)

* fix(server): plan 4 — RULE12 paren-count scanner ignores string literals

The entity generator's RULE12_fixGarbledCheckExpressions CHECK-expression
paren-balance scanner counted every `(`/`)` character regardless of
whether it fell inside a single-quoted SQL string literal. A CHECK
expression like `status IN ('confirmed)', 'tentative')` has a literal `)`
inside its first string value; the scanner would hit depth 0 at that
in-string character and truncate the expression, losing its own real
closing paren. The scanner now tracks single-quote string state (handling
the doubled `''` SQLite escape) and skips parens while inside one.

Latent-bug fix — no shipped entity's CHECK expression has hit this shape
yet (3d's ledger: "nothing affected today").

* refactor(server): plan 4 — unify the trip-access predicate's Kysely copies

TripsRepository.findForViewer/listForUser/activeTrip each hand-wrote the
identical trip-access boolean (t.user_id = ? OR m.user_id IS NOT NULL)
through the Kysely expression builder. Task 7's security review (M1)
evaluated a shared helper and set it aside for time, falling back to the
TRIPREPO-041 cross-method parity test as a regression guard instead.

Extracted the boolean into one generic helper, tripAccessExpr
(_shared/trip-access.ts), following the reservation-visibility.ts
publicReservationExpr precedent: fully generic over <DB, TB> since the
predicate has no correlated subquery, so it composes across
TripSelectKyselyDB and ActiveTripKyselyDB without needing a narrower
fixed-alias shape. The LEFT JOIN itself stays hand-written at each call
site — tripSelectQuery's own docstring already explains why a shared join
helper can't be typed across these differently-joined outer queries.

feeds.service.ts and memories-access.service.ts no longer duplicate this
predicate at all (Task 2's facade-inline sweep already converted both onto
TripsRepository.findAccessible/getFeedTokenIfReachable/
listReachableActiveTrips, which read through the canonical MikroORM
QueryBuilder form, accessibleTripsQuery) — the remaining duplication this
task closes is the three Kysely copies inside this file.

TRIPREPO-041 (64/64 in the file) stays green as the unification's own
regression guard.

* fix(server): plan 4 — trek_photo_cache_meta.cache_key NOT NULL

The column was a bare TEXT PRIMARY KEY, which SQLite does not implicitly
make NOT NULL (unlike an INTEGER PRIMARY KEY rowid alias). Every writer
(TrekPhotoCacheMetaRepository.upsertMeta, the table's only INSERT path)
has always bound a real string — 3e's R5 confirmed this. Appends a new
migration rebuilding the table with cache_key NOT NULL (SQLite has no
ALTER COLUMN, so this follows the same rebuild recipe
Migration20200101020000 uses in the opposite direction) and updates the
entity/generated metadata to match. Schema tightening, not a behaviour
change.

* refactor(server): plan 4 — retire the safely-convertible string-form where conditions (rule 23)

Places.repository.ts's existsInTrip/findInTrip/findRaw/reclaimInputs/
listByTripAndIds and Trips.repository.ts's findPublicForShare all took a
raw `.where('col = ?', [v])` string condition even though their id/trip_id
parameters were already plain `number` (or, for findPublicForShare,
narrowed to `number` here after confirming its one caller,
share.service.ts#getSharedTripData, only ever passes a number sourced
from a Kysely-typed share_tokens row, never an unconverted URL param) —
converted all six to MikroORM's typed `.where({...})` filter object.

The remaining raw string-form conditions in src/db/repositories/** are
NOT converted here: they are the `number | string` raw-bind seam
findAccessible/isOwner's own docstrings describe (trip_id/id threaded
through unconverted from req.params), which structurally requires the
deferred guard-level single toRowId parse to land first — narrowing any
of those methods' signatures now would either break the '0x10'/'007' id
parity those docstrings warn about, or duplicate the deferred item's own
work ahead of it landing.

* refactor(server): plan 4 — trek-photos.repository.ts finishes its rename

3e Task 6 renamed the CLASS (TrekPhotosRepository -> TrekPhotoRegistrationService,
since the name collided with the generated ORM TrekPhotos.repository.ts) but
left the FILE and its test file's names unchanged. Renames both to
trek-photo-registration.service.ts / .test.ts.

The old path is kept as a thin re-export stub (the src/websocket.ts shape)
rather than repointed at every one of its ~20 importers in this pass: several
of those files sit in two sibling tasks' exclusively-owned windows on this
shared branch (Task 3's budget/packing/todo/accommodations/places ripple,
reached through tests/helpers/plugin-host.ts and mcp-test-controllers.ts;
Task 5c's held-back tests/unit/services/{trekPhotoMedia,conflictUpdate}.test.ts)
and are off limits here. Flagged for a follow-up once those tasks land: delete
the stub and repoint every remaining importer at ./trek-photo-registration.service.

* fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic

install/discovery.ts#upsert refreshed a plugin's actions and settings-field
descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no
transaction spanning either pair, let alone both: a crash between a delete
and its own reinsert left that plugin with an EMPTY actions or
settings-field set until the next discovery run, instead of its previous
still-valid rows. Wraps both pairs in one uow.transactional, so a discovery
refresh for a plugin's descriptors is now all-or-nothing.

Deliberate behaviour change, named for the user per the task brief.

DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other
production caller, registry.service.ts) picks it up the same @Optional()
way PluginRuntimeService already does, with the same guard-throw. Updates
the two hand-constructed test call sites that actually exercise discovery
(discovery.test.ts, registry.test.ts) to pass a real UnitOfWork.

* refactor(server): plan 4 — facade inline sweep part B (rosterUserIds/getPlaceWithTags)

Inline DatabaseService.rosterUserIds onto TripMembersRepository.rosterUserIds
directly in budget.service.ts, budget.mcp.ts and todo.service.ts, and
DatabaseService.getPlaceWithTags onto PlacesRepository.findWithTagsAndRatings
in accommodations.service.ts (placesRepo was already injected there).

BudgetService/BudgetMcp/TodoService collapse their constructors, dropping
the now-fully-unused DatabaseService param (budget.module.ts/todo.module.ts
register TripMembers on forFeature). PackingService keeps DatabaseService as
a vestigial param behind an optional TripMembersRepository with a fallback
in tripRosterIds: an in-flight Task 5c file
(tests/unit/services/conflictUpdate.test.ts) hand-constructs PackingService
positionally without the new repository, so its arity cannot change this
task; the fallback keeps that file's DatabaseService.rosterUserIds spy
working until Task 4 drops the param with the class itself.

* test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline

Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).

* fix(server): plan 4 — thread the discovery UnitOfWork through PluginRuntimeService too

Complements the just-landed discovery atomicity fix (DiscoveryRepos.uow):
PluginRuntimeService's own discoveryRepos getter still built the bundle
without a uow field. Threads its already-injected, @Optional() uow through
so its own onApplicationBootstrap/reload/dev-link discovery calls also get
the transactional DI5-DI8 refresh, not just PluginRegistryService's.

No throwing guard here, unlike PluginRegistryService's: several hand-built
partial-DI-graph test instances of this class (boot-registry-order.test.ts,
in a sibling task's held file window) construct it with uow omitted and
exercise onApplicationBootstrap's discovery call directly. Discovery must
never refuse outright just because a test built a partial graph, so it
degrades to the pre-existing untransacted sequence when uow is absent
(DiscoveryRepos.uow's own docstring covers this), the same "boot must
never block app init" defensiveness this file already applies elsewhere.

* fix(server): plan 4 — guard-level single parse: verify + correct the overclaiming verifyTripAccess docstring (L-2)

TripAccessGuard/TripOwnerGuard already parse :tripId exactly once
(Number(), guarded by Number.isFinite) and hand the resolved TripAccess
row to the handler via request[TRIP_REQUEST_KEY], TripOwnerGuard reusing
TripAccessGuard's own lookup when both are on the same route rather than
re-querying — verified by reading both guards directly, no change needed
there.

PlacesService.verifyTripAccess's own docstring separately claimed its
returned `tid` gets "returned... for every downstream call to reuse
(never re-parsing)" (3c's Task 9 review, L-2, carried to Plan 4). Reading
every call site (not assumed) shows this is false: `.tid` has exactly one
reader (PlacesController.requireTrip itself), which discards it — none of
requireTrip's 13 callers read `.tid` off the trip it returns; each passes
the original raw tripId string on to the write/read method it calls next,
which toRowId-parses it again itself. That second parse cannot be
eliminated by this gate alone: the same service methods are called
directly by places.mcp.ts too, an entry point with no requireTrip gate at
all (its own numeric tripId is re-stringified, String(tripId), to call
them), so each method owns its own id validation regardless of entry
point. Threading `tid` all the way through would mean giving every one of
~15 methods a second, number-only call shape for the REST path alone to
use — a real refactor, out of this docstring fix's own scope. Corrected
the docstring to say so plainly instead of repeating the false claim.

* refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub

JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a
3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/
JourneyEntryPhotosRepository were still mid-flight). Now that those
repositories are stable, relocate: JS7/JS10 (photo/asset validation joins)
onto JourneyPhotosRepository (already injected into JourneyShareService as
photosRepo, no constructor change); JS13 (public entry list) onto
JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo
read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped
against that repository's own JP_COLUMNS select list instead of a
hand-duplicated copy. JourneyShareService gains two new @InjectRepository
params (both already registered in journey-domain.module.ts's forFeature),
rippled through mcp-test-controllers.ts and journey-share.service.test.ts
(P04/P05 parity tests now pin the new homes).

* refactor(server): plan 4 — document unreachable ?? default arms after single-row aggregates

3f Task 7's fix wave flagged a cluster of `row?.x ?? default` arms after
unqualified, ungrouped aggregate reads (MAX/COUNT/COALESCE(SUM,0)) as
"same class as OauthClients:147" but left them unexamined per-site. Decided
per site: document, don't delete — TypeScript still types
executeTakeFirst()'s result as `| undefined` even though SQL guarantees
exactly one row for these unqualified aggregates, so the optional chain
can't be removed without a non-null assertion (worse, not safer). Six
sites: JourneyEntries.repository.ts (maxSortOrderForDate JG42,
countStatsPlaces JG67), JourneyEntryPhotos.repository.ts
(maxSortOrderForEntry JG91), JourneyPhotos.repository.ts (maxSortOrder
JG88/JG99), VacayEntries.repository.ts (sumFraction VC1, sumCompFraction
VC2). No behavior change — comments only.

* refactor(server): plan 4 — unify the two user-data-erasure enqueue paths

UserCleanupService.erasePluginUserData (the account-deletion transaction's
own UC2/UC3 enqueue) and PluginRuntimeService.enqueueUserErasure (the
emitUserDeleted sink's post-commit enqueue) independently walked the
identical SELECT id, permissions FROM plugins + JSON.parse + hook:user-data
filter + INSERT OR IGNORE — and both genuinely run for every account
deletion, since every emitUserDeleted call site (auth.service.ts,
admin.service.ts, trip-members.service.ts's guest removal) fires
immediately after deleteUserCompletely.

Extracted the shared filter into enqueueHookUserDataErasures
(nest/plugins/user-erasure-enqueue.ts) and pointed both call sites at it.
UC2/UC3 also come off DatabaseService onto PluginsRepository/
PluginUserErasureQueueRepository in the same change (they were the last
raw statements standing in the way of sharing one implementation) — this
narrows the Plan 3b Task 5 "stays raw, owned by Plan 3j" ruling to UC1
only (plugin_user_config/plugin_oauth_tokens/plugin_oauth_state), which
still stays raw, unaffected. UserCleanupService's orphan-data-directory
scan (a plugin uninstalled with data retained) has no equivalent on the
enqueueUserErasure side and stays there, now via the same repository.

Both enqueue calls still run per deletion (INSERT OR IGNORE keeps this
idempotent) — this unifies the IMPLEMENTATION, not the call count; not
touching the emitUserDeleted event-sink wiring itself was a deliberate,
smaller-blast-radius choice for this task.

* refactor(server): plan 4 — narrow TripMembersRepository.remove/TripsRepository.setOwner to number

Both accepted trip_id: number | string per 3c's carry note (their "stays
loose for other callers" reasoning), pending the guard-level single parse
landing first. Re-checked every caller directly: setOwner's one production
caller (trip-members.service.ts#transferOwnership) already passes trip.id,
a real number resolved earlier in the same transaction; remove's callers
are trips.rpc.ts (num(), already a validated number), trips.mcp.ts (Zod-
typed tool input, already a number) and TripMembersController.removeMember
(the one genuine raw-string caller, :id off the route with no class-level
TripAccessGuard on this controller by design).

TripMembersController.removeMember now parses :id once (toRowId, not
Number() — rule 15's NaN-into-SQL trap) and threads the parsed number to
both its own access check and removeMember, instead of passing the raw
string through twice. TripMembersService.removeMember and both repository
methods narrow to number accordingly; setOwner's WHERE also moves off its
raw-bind escape hatch onto nativeUpdate now that trip_id is typed, and
remove's trip_id half moves onto a typed .where({ trip: trip_id }) filter
(user_id keeps its own raw bind — a different, still-live NaN-safety seam,
unrelated to this narrowing).

TRIPREPO-018 and TMEMREPO-023, which pinned the retired string-bind seam
on setOwner/remove, no longer typecheck against the narrowed signatures
and are removed with an explanatory comment in their place.

* refactor(server): plan 4 — U6 gate-level id parsing for todo item ids

Rule 21's carry from 3e Task 8's review: TodoController.update/.remove
passed the raw :id route string all the way to TodoItemsRepository,
relying on SQLite's column-affinity CAST to match it instead of a typed
filter. Parse :id ONCE at the controller (toRowId, matching Task 8a's
TripAccessGuard/removeMember shape), 404 on a non-canonical id, and thread
the number down through TodoService.updateItem/deleteItem into
TodoItemsRepository.findInTrip/existsInTrip/update/deleteById, all
narrowed from number | string to number. todo.rpc.ts's num()-derived
todoId no longer needs its String() wrapper. tripId stays string | number
— a separate, already-accepted carry (23c292f2f). Two new e2e tests pin
the non-numeric-id 404 at the gate.

* test(server): plan 4 — full-key parity tests for ShareTokensRepository (3h L4)

3h L4's carry: ShareTokensRepository (SH1-SH6/SH17/UC6) had zero tests.
17 cases pinning every read against the legacy statement run raw on the
same connection — findRawByTrip/findTokenByTrip with every nullable
column both null and set, findValidByToken/findTripAndShareMapByToken/
findTripIdByToken's shared token+expiry predicate (valid, future-expiry,
past-expiry, unknown token), plus insertNew/updateFlagsByTrip/deleteByTrip/
deleteByCreator's write correctness (deleteByCreator's cross-trip erasure
kept distinct from deleteByTrip's single-trip scope, per the repository's
own docstring). The listPublicForShare family (the other half of 3h L4)
is a separate, larger carry — not in this file.

* test(server): plan 4 — full-key parity for Reservations share/public-api reads (3d carry)

Task 8b-2 item 1 (partial): the 8 share.service.ts/public-api.service.ts
reads on ReservationsRepository flagged by the 3d Task 7 review as having
no repository-level toEqual(<legacy raw>) coverage (listEndpointsForShare,
listDayPositionsForShare, listPublicForShare, listPublicAccommodationsForShare,
listScheduledForPublicApi, listUnscheduledForPublicApi,
listAccommodationsForPublicApi, listUnplannedPlacesForPublicApi). 12 new
cases against the legacy statement run raw on the same connection.

* test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry)

Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged
by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService
.save's belongs-check-then-upsert, and FeedsService.generateTripToken's
read-then-write. Both tests pin today's actual outcome (an atomic ON
CONFLICT upsert survives cleanly; the token mint has a last-write-wins
loser) with the same concurrent-request-shape Promise.all/allSettled
pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix.

* test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry)

Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d
Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1),
DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery
LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's
"findTrackInTrip" — the actual method name). Completes the 11-file carry
together with 434113937's 8 share/public-api reads.

* test(server): plan 4 — dedupe overlapping listPlanDays/isTrackInTrip parity blocks

235f03665 landed two versions of the same DaysRepository.listPlanDays and
PlacesRepository.isTrackInTrip parity coverage side by side (a merge
artifact from concurrent work on the same carry item). Keeps one block per
method, drops the duplicate describe/it blocks and their colliding test ids.

* test(server): plan 4 — budget calc suite onto real repositories (3e carry)

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3)

Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged
as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings),
document_connections.secrets (DocSyncConfigService's whole-secret-map
column), and trip_document_links.webhook_secret (createLink's per-binding
mint). Each test saves through the real service, reads the raw column via
better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never
contains the plaintext, and asserts the service's own decrypt path rounds
it back to the original value.

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync secrets (3h L3 carry)

Task 8b-2 item 4: the 3h Task 7 review's L3 finding ("users.airtrail_api_key
has no at-rest pin", "document_connections.secrets ... has no enc:v1: check",
"trip_document_links.webhook_secret - no at-rest pin"). Three targets, each
proven by reading the raw column and asserting the stored value is
enc:v1:-prefixed ciphertext that round-trips, never the plaintext:
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow at the
repository layer, AirtrailService.saveSettings/getAirtrailCredentials at the
service layer, and DocSyncConfigService's connection secrets map plus
createLink's webhook secret.

* test(server): plan 4 — drop duplicate AirTrail encryption repository test

Task 8b-2 item 4 follow-up: 6965869dd already covers
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow's at-rest
ciphertext at the service layer (airtrail.service.test.ts's
AIRTRAIL-SVC-ENC-001/002) plus the doc-sync secrets/webhook pins
(DOCSYNC-ENC-001/002); this repository-level file duplicated the same
AirTrail assertion a second time.

* test(server): plan 4 — atlas read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (atlas half): full-key toEqual(<legacy raw>) coverage for
the atlas read models the 3f Task 7 review's L6 finding listed as missing
(minus AT1/TRAVELER_OWNS-consumer, already landed) — AT2 (Places.
listForTripIds), AT3/AT28 (PlaceRegions.listCountryCodesForPlaceIds/
listForPlaceIds), AT5/AT10/AT43 (VisitedCountries.listCodesForUser/
listForUser), AT21/AT24/AT25 (VisitedRegions.listForUser/
listRegionCodesForCountry/findCountryCode), AT22 (HiddenRegions.listForUser),
AT23/AT40/AT44 (PlaceRegions.listDistinctRegionCodesForCountryAndPlaces/
countPlacesByCountryForTrip/listVisitedCountryCodesForUser), AT30/33/34
(BucketList.listForUser/findById/findForUser), AT41 (Places.
listAddressesForUser), AT42 (Trips.countTripsAndDaysForUser). New standalone
files for HiddenRegions/VisitedCountries/VisitedRegions/PlaceRegions, which
had none. AT6/45/46 (the TRAVELER_OWNS endpoint reads) are proven on ids
only by the existing consumer test — full-row parity for those three is
NOT done here (carried forward, same as the review's own open item).

* test(server): plan 4 — vacay read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (vacay half): full-key toEqual(<legacy raw>) coverage for
the vacay read models the 3f Task 7 review's L6 finding listed as missing
(minus VC81/82, already landed) — VacayPlans.findByOwner/findById (the
getPlanData/getStats composite's own plan read), VacayYears.listForPlan
(listYears), VacayCompanyHolidays.listForPlan (the company-holidays list),
VacayHolidayCalendars.listForPlan/findById (holiday-calendar reads),
VacayUserSettings.findForUser (user year settings), VacayUserYears.
findForYear (getStats rows, including the NULL vacation_days case the
review named explicitly), and VacayPlanMembers.listAvailableForFusion/
listPendingForPlan/listPendingForUser (the fusion and share pickers). Seven
new standalone files — only VacayShares had a repository-level test before
this. getSharedCalendars itself is a service-level composite over these
same reads plus VacayCompanyHolidays.listDatesForRange (already exercised
elsewhere); its own dedicated test is NOT added here.

* test(server): plan 4 — full-key parity for PackingItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH13 read. Seeds Common items with every nullable
column both null and set, plus Personal and Shared-with-people items on
both sides of the is_private/owner/recipient predicate, and proves only
the Common tier surfaces.

* test(server): plan 4 — full-key parity for BudgetItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH14 read. Seeds two items — every nullable column
set and every nullable column null — plus a foreign-trip item, and proves
the ORDER BY category ASC shape (unlike BG72's listAllForTrip, which has
none).

* test(server): plan 4 — full-key parity for CollabMessagesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH16 read. Seeds a root message, a reply (reply_to
set), a deleted message (must be excluded) and a foreign-trip message, and
proves the narrower username/avatar-only join shape (no reply_text/
reply_username, unlike joinedQuery).

* test(server): plan 4 — full-key parity for PlacesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH12 read. Seeds a categorised place with every
nullable column set and an uncategorised place with every nullable column
null, plus a foreign-trip place, and proves the named 20-column allow-list
never leaks an owner-only column (reservation_status, google_place_id,
…).

* test(server): plan 4 — full-key parity for DayAssignmentsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the SH9 read. Seeds assignments across multiple days (one excluded day_id
never leaks in), every nullable place/assignment column both null and
set, and proves the six owner-only columns SharePublicAssignmentRow omits
(google_place_id, google_ftid, osm_id, amap_poi_id, stop_type,
fill_percent) never appear on the row at all.

* test(server): plan 4 — collapse remaining test harnesses; triage legacy migration tests

* test(server): plan 4 — todo controller test expects the gate-parsed item id (U6 follow-up)

* fix(server): add the missing numbered migration for the booked-night reseat step (#242)

reseatBookedNights (legacy step 242) was never invoked from any shipped
MikroORM migration -- only from the retired db/migrations.ts array --
so an install upgrading through the numbered chain never got the
one-time reorder. Adds Migration20200101040300, which calls the frozen
function through the native better-sqlite3 handle so it runs inside
the migration's own transaction.

orm-driver.ts's BoundSqliteConnection now also captures that handle
onto the field SqliteConnection.getNativeClient() reads, which its
createKyselyDialect() override previously left unset -- needed for
this migration (and any future one) to reach it in production, not
just under the bare test driver.

RESEAT-009 moves off the legacy createTables+runMigrations builder
onto the real Migrator via tests/helpers/migration-step.ts, plus a
new fresh-install guard (RESEAT-011).

* test(server): plan 4 — swap 4 of 5 straggler nest test harnesses off legacy schema builder

day-notes.service.test.ts, public-api-scopes.test.ts,
registration-invites.service.test.ts, trip-invite.service.test.ts onto
createSnapshotTestDb() via the async vi.mock idiom (5a/5b/5c precedent),
dropping their own hand-rolled createTables()+runMigrations() harness.
trip-invite.service.test.ts and registration-invites.service.test.ts
also drop their now-redundant DatabaseService wrapper (its .connection
was always just the same test db handle). expect( assertions unchanged.

user-cleanup.service.test.ts held back: its constructor call was
updated to match UserCleanupService's new EntityManager-based shape,
but deleteUserCompletely() now deadlocks against a real UnitOfWork
(reproduced in isolation, independent of this test file -- see the
report), which looks like a genuine bug in erasePluginUserData's new
MaintenanceRepository(em) call versus UnitOfWork.transactional(), not
a test-harness problem.

* test(server): plan 4 — move auto-backup.test.ts and demo-reset-path.test.ts off createTestDb()

Both build a schema db while 'fs'/'node:fs' are mocked (wholesale in
auto-backup.test.ts, selectively spied in demo-reset-path.test.ts) for
their own archiver/filesystem assertions, which broke
createSnapshotTestDb()'s read of the schema snapshot through
schema-snapshot.ts's own fs import.

auto-backup.test.ts: fs is mocked wholesale, so the snapshot db is now
built via a small helper that points fsMock's three calls at the real
filesystem (fetched through vi.importActual, bypassing the mock) just
long enough to build the db, before the test installs its own
existsSync stub -- never touching schema-snapshot.ts itself.

demo-reset-path.test.ts: fs.existsSync is selectively spied to answer
only for the baseline path, so the db is now built before that spy is
installed instead of after.

* test(server): plan 4 — retire the legacy-runner-only migration hygiene checks

migration-hygiene.test.ts deleted outright: every block in it (the
destructive-op allowlist, the empty-catch guard, the full-chain smoke)
scans or runs the legacy db/migrations.ts array being deleted this
plan, never the numbered src/db/migrations/*.ts chain, so there is
nothing in it to carry forward.

leg-mode-incoming.test.ts: deleted its first describe block (the
legacy-runner column-add + replay-safety smoke, also against
db/migrations.ts); the read-path parity block, already on the modern
createSnapshotTestDb() harness, is untouched.

* chore(server): delete the legacy schema/migration/seed builders

MikroORM's migrations are the only schema source now, including for
tests: src/db/schema.ts, src/db/migrations.ts and src/db/seeds.ts are
gone, along with tests/unit/db/schema-parity.test.ts (the parity gate
that guarded them, already red on 8a's cache_key NOT NULL) and
createTestDb() + its now-dead imports out of tests/helpers/test-db.ts
(its last two callers moved onto createSnapshotTestDb() this plan).
Their three entries drop from eslint.config.mjs's better-sqlite3
allow-list -- the files they exempted no longer exist.

Ruling 7's frozen files (migration-utils.ts, reseat-booked-nights.ts,
document-provider-seed.ts) and every numbered migration in
src/db/migrations/ are untouched.

Precondition grep (server/tests + server/src, from the task brief) is
now empty:

  grep -rlE "from ['\"].*db/schema['\"]|from ['\"].*db/migrations['\"]|from ['\"].*db/seeds['\"]" server/tests server/src --include="*.ts"

* docs: plan 4 — the repository layer is the only data access path

DatabaseService/DatabaseModule are gone; queries go through repositories
(server/src/db/repositories/) and UnitOfWork (server/src/nest/database/
unit-of-work.ts) for transactions. MikroORM's migrations are the only
schema source, including for tests — the hand-kept schema.ts/migrations.ts/
seeds.ts test builders and their parity test are deleted. Documents the
db/database.ts (connection only) / db/orm-driver.ts (bound driver) /
UnitOfWork / repository shape, withRequestContext for non-HTTP entrypoints,
the better-sqlite3 ESLint restriction, and the entity generator (gen:entities/
check:entities) as the schema-parity guard. Corrects two stale nest/README.md
mentions (realtime.gateway.ts's now-repository constructor, the BE-Phase 1
trip-access bullet) and marks the legacy src/services/ migration recipe as a
historical changelog rather than current guidance.

* refactor(server): plan 4 — drop the 13 vestigial DatabaseService params/getters

Removes the now-dead DatabaseService injection (or private get db() getter)
from addons.service.ts, trips.service.ts, trip-invite.mcp.ts,
place-photo-cache.service.ts, trek-photo-registration.service.ts,
categories.mcp.ts, journey-domain.service.ts, plugins.service.ts,
plugin-runtime.service.ts, plugin-mcp-tools.service.ts, places.service.ts
and packing.service.ts — every one of them was fully repository-backed
already (Task 2/3's facade inline sweep), just still carrying the param.

PackingService's TripMembersRepository is required now (the
this.db.rosterUserIds fallback is gone with it) and PluginRuntimeService's
uow is no longer @Optional() — both moved ahead of the optional
registry?/hostFactory? params, since TypeScript refuses a required
parameter after an optional one; install/discovery.ts's DiscoveryRepos.uow
is required to match, and its upsert() always runs the descriptor
refresh inside one transaction instead of falling back to an
un-transacted sequence.

user-cleanup.service.ts's UC1 (the one genuinely live raw site, Plan 3b's
"stays raw" plugin-table trio) moves off DatabaseService onto a new
MaintenanceRepository.deletePluginUserData method — the rule-4-permitted
connection.execute() home, built from a directly-injected EntityManager.
Threads the active UnitOfWork.transactional's transaction context through
execute()'s ctx param: without it, a raw connection.execute() call issued
from inside deleteUserCompletely's open transaction contends for the same
single-connection checkout the transaction already holds and deadlocks
(never released, since the transaction is itself awaiting the call).
tests/unit/db/transaction-yield.test.ts's probe B already pinned this
exact hazard class for a raw better-sqlite3 statement; this is the same
mechanism reached through the ORM's connection pool instead.

nest/photos/trek-photos.repository.ts (the Plan 4 Task 8a re-export shim)
is deleted, with every importer repointed to trek-photo-registration.service
directly. The ~7 files that only imported the TripAccess/PlaceWithTags
type re-export from database.service.ts now import them from
Trips.repository.ts/Places.repository.ts directly, the actual single source.

* refactor(server): plan 4 — delete DatabaseService, DatabaseModule and database.tokens.ts

Grep gate confirmed zero src/ importers of DatabaseService left outside
these three files (Task 1/2/3's earlier conversions + this task's own
vestigial-param sweep). Deletes database.service.ts, database.module.ts
and database.tokens.ts, drops DatabaseModule from app.module.ts's imports
and the five other modules that still listed it
(accommodations/calendar/doc-sync/feeds/trip-members) — none of them had
a live DatabaseService injection left, so the import was already dead.
OrmModule is the module that survives DatabaseModule's deletion, per the
Phase 0 ledger's own ruling; untouched.

db/database.ts's `db` Proxy export itself stays, deliberately: ~190 test
files' vi.mock('…/db/database', …) factories return a { db: <mock
handle>, … } shape, and their own `import { db as testDb } from
'…/db/database'` is typed against this file's real exports regardless of
the runtime mock — vi.mock swaps the module at runtime, but tsc resolves
the import statically. Deleting the export would fail typecheck:tests
across the whole suite for a test-harness idiom this task's file set
doesn't own (Track B's territory). src/ has zero remaining importers, so
the ESLint no-restricted-imports gate this task also shrinks still holds
this file to its permanent allow-list entry either way.

* test(server): plan 4 — repoint the test suite off the deleted DatabaseService/DatabaseModule

Mechanical follow-through of the previous two commits across ~110 test
files:

- The 41 tests/e2e/*.e2e.test.ts suites composed DatabaseModule into their
  partial Test.createTestingModule graphs; the import and the array entry
  are dropped (nothing in any composed domain module injected
  DatabaseService any more, so the entry was already dead weight).
- The shared test helpers (mcp-test-controllers.ts, plugin-host.ts,
  accommodations-service.ts, notifications.ts, test-addons.ts,
  test-uow.ts) each held a DatabaseService/DatabaseService-shaped param
  used only for its raw better-sqlite3 handle; every one now takes that
  handle directly, and every constructor call site the helpers make
  (PackingService, PlacesService, JourneyDomainService,
  PlacePhotoCacheService, TrekPhotoRegistrationService, TripsService,
  UserCleanupService, AddonsService, PluginsService, PluginRuntimeService)
  drops the removed param and, where PluginRuntimeService's uow is
  involved, reorders it ahead of the optional registry?/hostFactory?
  params to match the real constructor.
- ~60 further unit/integration test files hand-constructed one or more of
  those same services directly; same fix, file by file. Four of them
  (memories-access.service.test.ts, places.directions.test.ts,
  places.service.test.ts, unified-memories.service.test.ts) also drop a
  vi.spyOn(DatabaseService.prototype | instance, 'canAccessTrip' | 'isOwner'
  | 'rosterUserIds' | 'getPlaceWithTags') spy left over from when those
  methods delegated through DatabaseService — dead now that the services
  read the owning repository directly.
- database-service.test.ts and database-service.delegation.test.ts tested
  DatabaseService directly and are deleted with it.
- tests/unit/db/transaction-yield.test.ts's probe B, which pinned a raw
  DatabaseService statement's dirty-read behaviour inside an open
  transaction (rule 24), now issues the same raw better-sqlite3
  statement directly against the shared test handle — the exact shape
  DatabaseService.get/run used to wrap (this.conn.prepare(sql).get/run(),
  bypassing MikroORM/Kysely entirely) — so the hazard it documents is
  unchanged. Verified still green.

No expect(...) assertion changed in any file; only construction/import
plumbing. typecheck, typecheck:tests and the full touched-file run
(101 files / 2668 tests) are green, plus the compiled boot
(bootstrap.test.ts + boot-sweeps-request-context.test.ts) and one real
e2e boot file (trips.e2e.test.ts).

tests/unit/nest/user-cleanup.service.test.ts is deliberately NOT in this
commit — see the next one.

* test(server): plan 4 — finish user-cleanup.service.test.ts's harness swap; regression-test the UC1 transactional deadlock fix

This file arrived already modified and unstaged in the shared tree —
Task 6 (the parallel legacy-schema-deletion task) had swapped its DB
harness onto createSnapshotTestDb() and updated the UserCleanupService
construction to this task's new EntityManager-first shape while landing
its own commits. Finished here: USER-CLEANUP-005 ("survives a slim
schema without the plugin tables") passed a fake `{} as unknown as
EntityManager`, which doesn't reach the real UC1 path any more (the
previous raw DatabaseService.run swallowed everything in a try/catch
regardless); it now builds a real EntityManager bound to the slim
handle via sharedTestOrm(slim), so the per-statement query against the
missing plugin_user_config/plugin_oauth_tokens/plugin_oauth_state
tables is what's actually being proven caught, not a construction-time
throw.

USER-CLEANUP-006 through -010 (deleteUserCompletely, all of them
exercising UC1 inside the real uow.transactional wrap) are the
regression test for the previous commit's deadlock fix in
user-cleanup.service.ts/MaintenanceRepository.ts: before that fix, every
one of these hung and timed out. Green now, verified directly.

* chore(server): plan 4 — shrink the better-sqlite3 ESLint allow-list

Per ruling 6: database.service.ts's entry comes off (the file is
deleted). The five plugin entries (plugin-route-normalize.ts,
plugin-host-state.ts, install/discovery.ts, settings-defaults.ts,
signature-status.ts) come off too — re-verified none of the five import
better-sqlite3 or DatabaseService any more (3j's own conversion). The
src/demo/** blanket entry was already gone (3i's own task landed it).

reseat-booked-nights.ts and document-provider-seed.ts stay, with the
`// TODO(plan 4)` comment corrected: they're frozen forever (ruling 7),
invoked from inside numbered, already-shipped MikroORM migrations, never
Plan 4's or any future plan's to touch.

Permanent allow-list after this commit: src/db/database.ts,
src/db/orm-driver.ts, src/db/durability.ts (the driver homes),
src/nest/plugins/host/plugin-data.service.ts (3j's R-out-of-scope — a
plugin's own sqlite file is never TREK's schema), src/nest/backup/backup.impl.ts
(3i's R1, exactly the untrusted-upload-file probe), src/db/reseat-booked-nights.ts
and src/db/document-provider-seed.ts (frozen forever). src/db/{schema,migrations,seeds}.ts
entries were already removed by Task 6 when it deleted those files.

0 new lint errors; warning count unchanged at 2123 (well under the 2247
lean-gate ceiling) — every file the shrink touches was already fully
converted.

* refactor(server): plan 4 — U6 gate-level id parsing for budget item/settlement ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:settlementId/:userId route param in BudgetController is parsed ONCE at
the gate with toRowId, matching the legacy affinity-seam "not found" outcome
for a malformed id on each route (404 'Budget item not found'/'Settlement
not found', except toggleMemberPaid — the one legacy handler that never
404'd an unknown item/member either, so a malformed id there skips the write
and keeps the same 200 { member } + Number(id)/Number(userId) broadcast
shape it always had).

The parsed numbers thread down through BudgetService's update/updateMembers/
setPayers/toggleMemberPaid/remove/updateSettlement/deleteSettlement (and
their updateBudgetItem/deleteBudgetItem/setItemPayers/getBudgetItem/
applySettlementUpdate/getSettlement internals) into BudgetItemsRepository's
getCurrency/findInTrip/existsInTrip/findForDelete/findById/update/deleteById
and BudgetSettlementsRepository's findWithUsers/findGuard/update/deleteById,
all narrowed from number | string to number; trip_id stays loose, a
separate, already-accepted carry. costs.rpc.ts's num()-derived itemId no
longer needs its String() wrapper on the update/delete calls; budget.mcp.ts
was already Zod-typed to number throughout. Two new e2e tests pin the
non-numeric-id 404 at the gate; controller/service unit tests cover the
narrowed call args and the toggleMemberPaid malformed-id parity case.

* refactor(server): plan 4 — U6 gate-level id parsing for packing item/bag/template ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:bagId/:templateId/:userId route param in PackingController is parsed
ONCE at the gate with toRowId, matching the legacy affinity-seam "not found"
outcome for a malformed id on the routes with a pre-ORM Express precedent
(update/remove -> 'Item not found', updateBag/deleteBag/setBagMembers ->
'Bag not found', applyTemplate -> 'Template not found or empty').

#858's sharing/contributors routes (setSharing/clone/addContributor/
removeContributor) are native Nest code with no pre-ORM precedent (landed in
7eabf6066, after the migration) — a malformed id there just 404s the same
way an unknown one already does. removeContributor's :userId used to reach
contributorsRepo.deleteOne through a bare Number.parseInt(), the exact
NaN-into-SQL trap row-id.ts documents; it now parses through toRowId too.

The parsed numbers thread down through PackingService's updateItem/
setItemSharing/addContributor/removeContributor/cloneItem/deleteItem/
getItemPrivacy/updateBag/deleteBag/setBagMembers/applyTemplate, all narrowed
from number | string to number; tripId stays loose, a separate,
already-accepted carry. PackingItemsRepository's getPrivacy/findVisibleInTrip/
update/updateSharing/delete/findById, PackingBagsRepository's findInTrip/
update/findWithAssignee/delete and PackingTemplateItemsRepository.listForApply
narrow the same way. packing.rpc.ts's num()-derived itemId/bagId no longer
need their String() wrappers on the update/delete/updateBag/deleteBag/
setBagMembers calls; packing.mcp.ts was already Zod-typed to number
throughout. Two new e2e tests pin the non-numeric-id 404 at the gate;
controller/rpc unit tests cover the narrowed call args and the
removeContributor malformed-userId parity case.

AdminPackingTemplatesController (/api/admin/packing-templates) is out of
scope — a separate admin-gated surface with its own string-typed template
CRUD, not part of this trip-scoped controller's :id chain.

* test(server): plan 4 review — three pins catch up with the gate-parsed ids and the cache_key NOT NULL (M1–M3)

* fix(server): boot baselines legacy-runner installs before running MikroORM migrations (B1)

An install the retired positional runner migrated has a schema_version row
and no mikro_orm_migrations rows, so its first boot on this branch replayed
all 244 migrations over an already-migrated schema: eight refuse the boot,
and the replay-safe ones overwrite user data (step 26 rewrites every
day_assignments.assignment_time).

runSchemaBootstrap now records the baseline plus every migration whose
"Legacy migration step N" docstring is <= schema_version as executed, in one
transaction through the migrator's own storage, before the ordinary run
applies the rest. The step map is derived from the discovered migrations'
source, never hand-kept, and anything unmappable (a newer or malformed
schema_version, a duplicated, missing or out-of-order step) refuses the boot.

* test(server): boot the app on three legacy-runner fixtures to pin the B1 baseline

SQL dumps of databases the retired runner itself built: schema_version 205
(main at fa406b708), 242 (417d0445b, the runner's last commit) and 82
(v2.9.14, so seven of the eight non-replay-safe migrations still lie ahead
and run for the first time). Each gets canary rows: user-set assignment
times and a booked night.

buildApp() on each asserts the baseline and the applied rest in order, the
assignment times untouched, the booked-night reseat run exactly once on 205
and 82 and not at all on 242, schema_version kept, no half-built *_new
table, a schema identical to a fresh install's, and an idempotent re-run.

* refactor(server): demo-reset reads the live path off getRawConnection, not the db Proxy (m1)

demo-reset.ts was the one src/ consumer of the db Proxy left, reached
through a runtime require that a grep on `from` misses. It now imports
closeDb/getRawConnection/reinitialize statically, the way backup.impl.ts
does, so the Proxy genuinely has no src/ importer (the claim in database.ts
and server/CLAUDE.md now holds). The path test mocks the module with vi.mock
instead of teaching Node's require loader to answer for a .ts file.

* test(server): renumber the duplicate ASSIGNREPO/PLACEREPO test ids Plan 4 introduced (m3)

ASSIGNREPO-031/032 (the legacy-statement parity pair) become 034/035, and
the later PLACEREPO-030..038 block becomes 053..061, the next free ids, so
each id names one test again. No test body changes.

* refactor(server): derive the two restated public row types instead of copying their columns (m4)

JourneyPublicEntryPhotoRow restated JourneyPublicGalleryRow's nineteen
columns with only the parent key changed; it is now that row with
journey_id swapped for entry_id. PublicApiAssignedPlaceRow and its Kysely
table shape restated PlaceRow's columns; both now Pick them off PlaceRow.
Types only. The review's CPD method over Plan 4's new src lines drops from
about 3.4% to 0.64%.

* test(server): full-key parity for TripsRepository.findPublicForShare and VacaySharesRepository.listAvailableForShare (m5)

Neither read had any test reference. Each is now pinned with
toEqual(<legacy statement run raw on the same connection>), the
listPublicForShare family shape: SH7 on a filled and a NULL-heavy trip plus
a missing id (TRIPREPO-067/068), and VC90 over one world exercising every
exclusion (caller, guest, existing share, plan owner, accepted member) and
a missing plan.

* fix(server): the legacy baseline runs createTables() instead of marking it, atomically with the run (N1)

The legacy runner ran the running release's createTables() on every boot
before its pending steps, and about 30 tables exist only there. Marking the
baseline migration as executed left installs older than those tables (below
legacy step 68) failing with "no such table: photo_provider_fields" and, since
the rows were committed before the run, stuck half-baselined for every later
boot.

Only steps 1..N are recorded now, so the baseline's IF NOT EXISTS DDL runs
first, as the legacy boot did. The rows are logged inside the same
transaction as migrator.up({ transaction }), so a failed first boot records
nothing and the next boot baselines again.

A v66 fixture (the runner at 5be2e9b26) boots: 66 recorded, 178 applied,
canaries kept. Its two schema differences from a fresh install
(users.immich_access_token, reservations.accommodation_id INTEGER) come
from the legacy chain itself and are pinned as carried.

* fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames

feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without
regenerating the lockfiles, so npm ci failed on every CI job. The root
overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11,
and the tests that select lucide icons by their old class names use the
current ones. Two tests cover the Immich prefs reads and the boot restore
branch that the coverage thresholds were missing.

* ci: give the server lint step an 8 GB heap for the type-aware rules

* test(server): keep the no-promise-as-value rule tester out of single-run mode on CI

With CI=true typescript-eslint infers single-run mode, builds the program
from the fixture file on disk and ignores the code each case passes in, so
all 24 invalid cases reported nothing on the runner. Both testers now set
disallowAutomaticSingleRunInference.

* refactor(server): name the new migrations after the first seven words of their purpose, like the rest

* fix(server): keep tsx a production dependency so image key rotation can load it

* test(server): re-pin the repositories branch floor at the measured 89.83%

* test(planner): keep the sidebar fixtures on past dates so the jump to today never fires in jsdom

* test(server): keep console output for failing tests only and teach Nest's logger LOG_LEVEL

* test(client): keep console output for failing tests only and answer the health probe

* test(client): drop the health handler, it races the admin page's first load

* chore(sonar): assert explicitly in two e2e cases, Number.NaN, and ignore S2187 on the legacy-upgrade fixtures

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-10-06 19:27:30 +02:00
Maurice 7cbd4e5c42 test(auth): cover the api-token routes
The three api-tokens endpoints landed without tests, which took
src/nest/auth/** under its coverage floor. Same four paths the
mcp-tokens block above already pins, plus the rate limit: unlike MCP
tokens these are not refused on a managed instance, so the limiter is
the only thing bounding a scripted caller.
2026-08-28 15:23:05 +02:00
Maurice 38f3cb3101 fix(server): the second pass over this branch's own fixes
An adversarial review of what had already landed here turned up three that
were wrong, and they are the reason for this commit.

The CSP change removed 'unsafe-eval', which is not leftover: heic-to's
libheif build initialises embind through new Function(), and without it every
iPhone .heic upload fails in the browser. 'wasm-unsafe-eval' alone was tried
once already and was not enough (93b51a0b). Put back, with the reason written
down this time and the test pinning it rather than its absence.

The idempotency in-flight guard never fired. finalize() runs when the
handler's observable completes, but Nest writes the response - and therefore
stores the row - several microtasks later, so the waiting request always woke
up before the row existed and ran the handler a second time. It releases from
the point the row is written now; finalize is the backstop for handlers that
answer another way. The test drove the two in the opposite order from Nest,
which is why it passed either way.

And the trip-scoping check treated an id that resolves to nothing like one
belonging to someone else. accommodation_id has no foreign key, so shortening
a trip's dates leaves a booking pointing at a gap - which update() has always
healed by clearing the link, and which would have become a permanent 400.

The rest: a reply to a soft-deleted chat message is refused and the create
path blanks the quote like the list path does; the ws-token buckets are keyed
by account rather than by IP, where every user behind one NAT shared a
ceiling; the weather and vacay caps read through the stream, since a chunked
response has no content-length to check; the Google host gate takes country
domains again; abandoned response bodies are cancelled instead of left to GC;
the boot failure prints its errno; and a share link that is not yours to read
now says so instead of reporting that the journey has none.

TICKETJSON gets a real repair: the in-place GLOB fix only helps installs that
have not run it yet, so an appended migration puts back the note text the
case-insensitive LIKE cut off, for rows where what it stored is not valid
JSON. A genuine receipt always parses, so it cannot be touched.

Wiki: the audit log records the IP Express resolves after TRUST_PROXY, not
the leftmost X-Forwarded-For entry a caller can write. That is the right way
round, but it means the hop count has to be accurate - said plainly in both
places now.
2026-08-23 11:59:12 +02:00
Maurice 8e5fdaa877 fix(server): tighten the guard, audit and token paths
Permissions cached an empty map before it had loaded, so a DB error during
the load cached permissive defaults and every later check answered from that
cache - fail-open, and it never expired. The rate limiter grew its per-IP map
forever; the GC the legacy version had was dropped in the fold. Idempotency
did a check-then-insert, so two concurrent duplicates both ran the handler.

Audit rows recorded whatever X-Forwarded-For said, which the client writes,
and the request-body debug logger redacted by exact key match - it missed
refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect
from the client's Host header. CSP asked for unsafe-eval on top of the
wasm-unsafe-eval it actually needs.

The OAuth deny path redirected to an unvalidated URI, ws-token minting had
no rate limit at all, and PKCE was skipped when code_verifier was absent
rather than refused. An MCP session resumed with a token whose scopes had
since been reduced kept the capability set it was created with, and the
session cap was checked before the async registration that increments it.

Plugin frame containment was lexical, so a symlink out of the directory was
never noticed, and instance config accepted keys the manifest never
declared.
2026-08-23 11:59:12 +02:00
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00
Maurice 1a0834b866 fix(maps): resolve the Places key per instance, not per whoever is asking
Searching worked for the admin and 403'd for every other member. getMapsKey read
the calling user's own row first and then fell through to "any admin" via LIMIT 1
with no ORDER BY, so the admin who pasted the key hit their own row while everyone
else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids.

The Places and Unsplash keys bill the install, not the account that pasted them
in, so they become instance configuration: an encrypted app_settings row, resolved
as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM
endpoint. The per-user column stays as the last step because /me/api-keys has
always accepted a personal key.

The trailing migration carries an existing install's key into the instance row so
nothing degrades to OpenStreetMap on upgrade, but only where that key already was
the whole install's: as soon as a second row holds one, it is left alone, because
the instance row outranks everybody's own column and an upgrade must not move a
member onto someone else's key and bill.

Two things from the same report:

The key test button probed with field mask places.displayName while the search
asks for places.rating and more, which is a separate SKU and can be restricted on
its own, so the button could pass while every search failed. It now probes the
resolved key with the search's own mask.

Saving API keys wrote no audit line. It now writes one settings.api_keys_update
row naming which fields changed, never their values, comparing cleartext because
the random IV makes every ciphertext look different.

Closes #1939
2026-08-17 14:48:23 +02:00
jubnl 2bffee8fd4 fix(auth): preserve remember-me when password change re-issues the session (#1927) 2026-08-15 22:48:22 +02:00
Maurice 4780a7be90 Typecheck the test tree, and fix what it found
server/tsconfig.json has include: ["src"], so tests/ was never typechecked.
A service that gained a constructor parameter did not break its hand-wired
call sites at compile time — the argument arrived as undefined and threw at
runtime, if anything read it at all. The notifications sweep hit 46 such
sites and only a stash-diff against a throwaway tsconfig found them.

tsconfig.tests.json makes that permanent. It is not wired into CI yet: the
tree still reports 170 errors, and turning it on now would block every
unrelated PR.

Three of the first fixes were not type noise.

DAY-SVC-034 asserted that createAccommodation rolls back a failed
reservation insert. That method moved to AccommodationsService in the
days/accommodations split, so the case was calling undefined — which throws,
which satisfied .toThrow(). The rollback has been unchecked ever since,
while the case reported green. Repointed at the owner; the invariant holds.

host-rpc.test.ts typed its response as RpcResponse & Partial<RpcError>.
Both carry ok, one as true and one as false, so the intersection is never
and all 97 property reads in the file were errors nobody could see.

auth.controller.test.ts passes {} as the body in cases that stub the service
and assert on the mapping around it. That is correct as written — the point
of several of them is that the error path never reads the body — so anyBody()
names the intent instead of inventing fields the assertions do not read.
Three changePassword calls were genuinely missing the @Res() argument.
2026-08-10 19:54:46 +02:00
Maurice 45611e2514 Separate the profile a user administers from the identity they log in with
The second cut into AuthService. Display settings, avatar, the third-party
API keys a user pastes in and the member directory are not identity: no
password is checked, no session is issued, no MFA secret is touched.

UserProfileService takes those eight methods verbatim, with DatabaseService
as its only injected dependency. AuthService keeps register, login, JWT,
MFA, password reset and account deletion, and is now roughly 900 lines
lighter than it started this branch.

Not exported from AuthModule: only its own controller needs it, and an
export nobody imports is noise.

The controller test harness builds AuthController positionally, so the two
new parameters landed in optional slots and the stubs for the moved methods
moved with them.
2026-08-09 22:43:05 +02:00
Maurice 03fbced20b Move tokens out of AuthService into a domain of their own
AuthService carried identity, profile, settings and tokens at once. Tokens
are the cleanest of those to cut loose: they touch one table plus the
ephemeral-token store, and nothing in them needs to know how a password is
hashed or how a session is established.

TokenService takes the six user-facing methods plus verifyMcpToken, and the
two admin-side ones that only lived on AdminService because the admin route
did. The pairs are genuinely different queries rather than duplicates, so
both survive under names that say which is which: the user-facing ones scope
every statement by user_id and treat the session sweep as best-effort, the
admin ones do neither.

Left behind on purpose: verifyJwtToken, which is login identity and belongs
next to the cookie logic, and isDemoUser, which is a demo gate that happens
to read the users table.

TokensModule is a leaf with nothing but DatabaseService, because auth, admin
and the MCP transport all import it and anything added here lands in three
graphs at once.

Both bridges and both controller test harnesses construct these classes
positionally and had to be pulled along; the new argument goes in an
optional slot so untouched call sites stay untouched.
2026-08-09 22:43:05 +02:00
Maurice e8d4278899 Give travel stats to the domain whose data it reads
getTravelStats sat on AuthService for historical reasons only. It reads
places, trips, visited_countries, place_regions and reservation_endpoints,
and subtracts the countries hidden in Atlas, so every one of its sources is
an Atlas concern.

Keeping it there had a real cost: AuthModule had to import AtlasModule, and
that edge is the reason atlas.mcp.ts reaches AuthService through auth.bridge
instead of injecting it. The direction is now free to reverse.

GET /api/auth/travel-stats keeps its path. The client calls it there, so the
route moves to a small controller in atlas/ that holds the /api/auth prefix
rather than changing the URL. PROFILE-014 runs against the full app and
proves the endpoint is untouched.

KNOWN_COUNTRIES moves along, since getTravelStats was its only reader. Both
bridges construct AuthService by hand and needed the argument dropped.
2026-08-09 22:43:05 +02:00
Maurice 22d178d2f9 refactor(server): one addon guard, one demo-mode condition (#1829)
Two pieces of copy-paste in the guard layer.

Three addon guards — collections, airtrail, journey — did the same thing and
differed only in the addon id they read and the word leading the 404. They are
one AddonGuard plus a @RequireAddon(addonId, label) decorator now, read through
the Reflector. A handler-level decorator overrides the controller's, and a route
without the decorator is not gated at all.

The demo-mode upload block was copy-pasted into six handlers, each carrying its
own copy of the message. All six call isDemoWriteBlocked now, against the same
DEMO_WRITE_ERROR body.

That one is deliberately NOT a guard, though all six sites are handlers and a
guard is the obvious shape — and this PR did build one first. Guards run before
the multipart parser, so throwing from one leaves the request body unread and
Node resets the connection: the client gets ECONNRESET instead of the 403.
PROFILE-015 caught it. The check has to sit inside the handler, after the
interceptor has drained the upload, for the response to arrive at all. The
function carries that reasoning so the next person does not rediscover it.

Two smaller findings on the way there.

The DemoWriteGuard attempt broke eight e2e suites that never touch demo mode:
AuthModule listed it as a provider, and RuntimeEnvService was not resolvable
inside a TestingModule built around one domain module. AppConfigModule is
@Global, but @Global only applies to modules that are in the graph. The five
modules whose controllers now inject RuntimeEnvService import it explicitly, the
same way PermissionsModule is handled.

And both new guards get a test asserting the decorators are actually applied.
Once a check moves out of a handler, that handler's own tests stop noticing if
the decorator is dropped — the assertions on __guards__ and the @RequireAddon
metadata are what keeps the four upload endpoints and the four addon route
groups gated, and gated on the right addon.
2026-08-07 18:42:53 +02:00
Maurice 6dda05f672 refactor(server): move the stateless helpers into nest/common (#1823)
Eight files in services/ hold no state and touch no database: avatarUrl,
conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two
crypto helpers. They are imported from 65 places, 62 of them already inside
src/nest, so having them live in the legacy layer was pure inertia.

They stay plain functions rather than becoming providers. Three importers sit
outside the container and would have no way to reach an injectable one:
db/migrations.ts runs before the app is built, and middleware/auth.ts and
middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony
and nothing else.

mfaCrypto and apiKeyCrypto move together into common/crypto — they read the
same ENCRYPTION_KEY, and the directory says so.

services/ drops from 44 files to 36, 9,870 lines to 8,493.

One trap worth recording: src/app-config and src/nest/app-config both exist.
cookie.ts imported '../app-config' from services/, which resolved to the former;
after the move the same string silently pointed at the latter, which has no
readEnv. It is now explicit about which one it wants.

Nothing else changes — no signatures, no behaviour, no tests rewritten beyond
their import paths.
2026-08-07 15:14:14 +02:00
Maurice e606ebd9b9 chore(server): pin the WebSocket envelope, dedupe the rate limiter (#1803)
* chore(nest): register LlmParseModule in AppModule

The module was only in the container because BookingImportModule and
PluginsModule happen to import it, so /api/admin/llm/local depended on those two
keeping that import. Register it where every other module is registered.

* refactor(nest): give the rate limiter one home and one instance

RateLimitService keeps its counters in a Map on the instance, but it was listed
in the providers of auth, oauth, transit and trip-invite, so each of them got its
own. Nothing is broken today — the bucket names in use (login/mfa/forgot/reset,
oauth_*, transit_*, trip_invite) don't overlap — but two modules picking the same
name would have quietly allowed twice the configured attempts.

Move it to nest/common, where it belongs now that four domains use it, and hand
it out through a small module they import. Not @Global: the e2e suites build a
container around one domain module, so a global AppModule never pulled in isn't
there at all.

transit.mcp.ts keeps its own instance on purpose — it keys by userId under
mcp_transit_* while the REST path keys by IP.

* test(ws): pin the wire envelope before touching the transport

Every frame on the socket is flat: {type, tripId, ...payload} out, {type, tripId}
in. Nest's WsAdapter dispatches on message.event and answers {event, data}, so
moving this onto a gateway without a custom serialiser would leave join silently
unhandled — no welcome.socketId to send back as X-Socket-Id, and every client
would then receive its own writes.

Nothing catches that today: the 100+ tests that touch realtime mock src/websocket
and never look at the bytes. WS-032 sends the exact shape the stock adapter would
and asserts the server ignores it.

* docs(nest): drop the parts of the README that no longer exist

strangler.ts, DEFAULT_NEST_PREFIXES/NEST_PREFIXES and tests/parity/ are all gone —
every request goes through Nest now — but the README still walks a new contributor
through adding a prefix and writing a parity test against the Express route.

Also spell out that the 80% gate is an average over src/nest/**, not a floor per
file, so a large untested module can sit behind the well-covered small ones.
2026-08-06 22:44:59 +02:00
jubnl 6ff936c613 chore: move auditLog backend to proper DI 2026-07-27 22:09:08 +02:00
Maurice ad893eb1cc Release 3.1.0 (#1185)
* Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)

Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).

* feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)

First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.

* fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)

Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.

* feat(i18n): add Korean (ko) translation (#977)

Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.

* feat(i18n): add Japanese (ja) translation (#829)

Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.

* Add Turkish (tr) translation + language registry (#1029)

Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.

* i18n: register Korean + add Ukrainian translation (#1055)

Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).

* chore: fix monorepo build pipeline and migrate shared to built package (#1056)

* chore: fix monorepo build pipeline and migrate shared to built package

- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
  that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
  from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
  paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
  eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
  needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files

* chore: fix test script to reflect root package.json

* chore: add missing lint and prettier script in root package.json

* fix(ci): build shared before tests; fix vitest MCP SDK alias paths

vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.

CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.

* fix(docker): update Dockerfile and CI for monorepo workspace structure

Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
  client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
  can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
  --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
  find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
  resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data

docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
  npm version --workspaces --include-workspace-root --no-git-tag-version
  (mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json

.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)

* fix(tests): use in-memory SQLite per worker in test mode

vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.

When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.

* chore(ci): add ACT guards to skip DockerHub steps in local act runs

act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard

Run locally with:
  ./bin/act -j build -W .github/workflows/docker.yml \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest

* fix(ci): move ACT guards to step level; add guards to security.yml

env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.

* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)

* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"

This reverts commit 67cf290cda.

* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"

This reverts commit f92b95e054.

* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"

This reverts commit 797183de08.

* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries

npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.

Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.

* fix(docker): copy client dist into server/public so the server resolves static files correctly

The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.

* feat(planner): real road routes (OSRM) with travel-time connectors (#1060)

* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs

* remove route_calculation setting, always use OSRM routing (#1064)

The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).

* chore: move i18n to shared package (#1066)

* chore: move i18n to shared package

* chore: move server translations to shared package and apply linter and prettier on entire shared package

* feat(dashboard): upcoming reservations endpoint + travel-stats country/distance

Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.

* i18n(dashboard): dashboard keys across locales

* feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix

Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles.

* i18n(dashboard): sync all locales to one key set + German copy-dialog strings

Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German.

* refactor(dashboard): replace hardcoded strings with i18n keys

Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German.

* feat(i18n): add Greek translation (#1061)

* i18n: complete Turkish (tr) translation (#1075)

Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish
matches the English catalog. Brand names, URLs, and technical placeholders
are left untranslated by design.

* chore: prettier + lint

* chore: enforce prettier & lint on shared package

* feat: Updated border of map markers to reflect category color. (#1062)

* feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)

* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

- Rework the mobile dashboard: cover hero, separate boarding-pass card,
  trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
  (new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
  plain-text countdown module, real place photos in the boarding pass

* i18n(dashboard): translate new dashboard keys across all locales

Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.

* feat(oidc): send PKCE (S256) in the OIDC login flow

The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).

* Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)

* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.

* Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094)

* Start the Journey date picker week on Monday (#1078)

The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers.

* Fix Taiwan resolving to CN-TW in the Atlas country search (#1049)

natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes.

* Drop empty leftover dateless days when a trip gets a shorter dated range (#1083)

generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017.

* Render GPX and route overlays once the Mapbox style has loaded (#1036)

The GPX and route geojson effects ran before the map 'load' event had
attached their sources, so on the first paint they hit the early return
and never re-ran. Add mapReady to their dependencies so they fire again
the moment the sources exist.

* Convert HEIC trip and journey covers to JPEG before upload (#1085)

HEIC/HEIF covers coming straight off an iPhone could not be rendered in
the preview or stored as a usable image. Route both cover pickers through
normalizeImageFile, the same conversion the journal entry editor already
uses, so the file becomes a JPEG before it leaves the browser.

* Name GPX routes and tracks after their source file so multiple imports stick (#1054)

Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.

* Namespace the modal backdrop class so content blockers stop hiding it (#1027)

Generic class names like .modal-backdrop sit on the cosmetic filter lists
that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden
with display:none. The shared Modal - used by New Trip and Add Place -
carried that class, so Safari users running such a blocker saw the modal
silently fail to open with no error and no network request. Rename it to
.trek-modal-backdrop.

* Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067)

A zoom-8 reverse geocode of a UK place only resolves to the constituent
country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB
are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes
match no polygon, so places in England never highlighted in the Atlas
while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent
country, re-resolve it at a finer zoom where Nominatim exposes the
county/borough code the polygons actually carry. Other countries keep the
exact zoom-8 behaviour. Adds ATLAS-UNIT-021.

* Surface the real place-search error instead of a generic toast (#1092)

When a place search or detail lookup fails, the backend already forwards the
upstream reason - including descriptive Google Places API messages such as
'Places API (New) has not been used in project ... or it is disabled'. The
planner discarded it and always showed 'Place search failed', so a key that
is mis-enabled, unbilled, or pointed at the legacy API instead of Places API
(New) looked like an unexplained silent failure. Show the server-provided
message when present, and stop the Atlas bucket-list search from swallowing
its error without a trace.

* Await the async cover normalization in the TripFormModal paste test (#1085)

handleCoverSelect now normalizes the pasted file before previewing it, so
URL.createObjectURL is called a microtask later. The assertion moves into
waitFor; a non-HEIC file still passes through unchanged.

* fix(pwa): removed orientation from the manifest (#1058)

* fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101)

* feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105)

Closes #718. Adds five new transport reservation types alongside the
existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic
'transport_other' catch-all. The new types are treated as first-class
transports everywhere — the transport modal, day plan, route calculation,
map overlays, file grouping and the PDF export — and are translated across
all 20 locales.

A dedicated 'transport_other' value is used for the catch-all so existing
'other' bookings are not reclassified as transport.

* feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)

* feat(reservations): native booking-confirmation import via KDE KItinerary

Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).

- NestJS BookingImportModule: preview + confirm endpoints under
  /api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
  handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
  BoatReservation, LodgingReservation, FoodEstablishmentReservation,
  RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
  coordinates via Nominatim (name+address → address → name fallback);
  resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
  with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
  ConfirmRequest, ConfirmResponse — consumed by controller, service,
  API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
  binary + locales; arm64 installs libkitinerary-bin + symlinks to
  fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
  hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner

* i18n: add booking import translations for all 19 non-English locales

Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.

* chore: enforce i18n parity

* docs(wiki): add KItinerary local setup instructions to dev environment guide

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)

* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.

* feat: Passkey (WebAuthn) login (#1111)

* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle

Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.

* feat(auth): passkey enrolment, login button + admin settings UI

PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.

* i18n(auth): passkey strings across all locales

Add login/settings/admin passkey keys to en and all 19 translated locales.

* chore: update kitinerary version

* Backend/frontend hardening & consistency cleanups (#1113)

* refactor(auth): session token validation and password-change consistency

* refactor(journey): entry field allow-list and public share-link consistency

* refactor(mcp): align tool authorization with the REST permission checks

* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)

* feat: optimize routes around accommodation, confirm note deletions (#1123)

Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.

* fix: miscellaneous bug fixes (#1139)

* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.

* Fix a batch of reported bugs (#1145)

* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)

* Support multi-leg (layover) flights (#1146)

* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.

* Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)

* feat(maps): add an OSM POI search endpoint (category within a viewport)

New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.

* feat(map): explore nearby places on the trip map (OSM category pill)

A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).

* fix(planner): anchor timed places when optimising and route transports by location

- The day optimiser no longer reshuffles places that have a set time — they stay
  anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
  has one (e.g. a flight's airport), instead of breaking the route at every booking;
  transports without a location are ignored for routing but still show their leg's
  distance/duration under the booking.

* feat(admin): instance-wide Mapbox defaults in default user settings

Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).

* Reorder whole days and insert a day (#589) (#1148)

* feat(days): reorder whole days and insert a day at a position

Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.

* feat(planner): reorder days from a toolbar popup, and add days

A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.

* i18n: add day-reorder strings across all languages

* Map/planner/dashboard polish and small community features (#1155)

* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.

* fix(packing): add more bag colors so sub-bags stop repeating (#1156)

The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].

* fix(packing): respect per-item quantity in bulk import (#1157)

* AirTrail integration: import flights & two-way sync (#214) (#1158)

* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).

* Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)

* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)

* fix(notifications): show notification and admin times in the viewer timezone (#1149)

SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.

* fix(places): warn before adding a duplicate place (#1152)

Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.

* fix(planner): make route tools reachable in mobile day plan sheet (#1142)

* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>

* feat(places): enrich list-imported places via the Places API (#886) (#1161)

* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.

* fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)

The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.

- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
  geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
  with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
  scheduler (startup + nightly), and removeIfUnreferenced() called on
  place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
  self-heal as the cache dirs are recreated at startup.

* fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175)

Closes three offline BLOCKERs from the PWA audit:

- B1: offline edits/deletes of an offline-created entity were lost. The
  negative temp id was baked into the PUT/DELETE url and never rewritten
  after the CREATE returned a real id, so dependents 404'd and were dropped.
  Dependents now carry a {id} placeholder + tempEntityId; flush builds a
  tempId->realId map and durably rewrites still-queued dependents on CREATE
  success (survives flush boundaries / reloads).
- B2: tempId = -(Date.now()) collided within a millisecond, overwriting an
  optimistic row. Replaced with a monotonic nextTempId() minter.
- B3: any 4xx marked the mutation failed with no rollback and no signal, and
  the badge ignored failed rows. Terminal failures now roll back the phantom
  optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount()
  is surfaced in OfflineBanner (red pill) and OfflineTab.

* fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)

Closes BLOCKER B5 — the offline map was blank for most real trips:

- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
  budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
  now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
  exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
  all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
  the budget, so large trips now cache the zooms that fit instead of nothing.

* fix(security): stop cross-user offline data leak on shared devices (#1176)

Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:

- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
  (cookie-blind). Changed to NetworkOnly; offline reads come from the
  per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
  per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
  stable binding; login opens the user DB, logout deletes it and returns
  to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
  re-seed the DB after the wipe. It is now async and ordered — close an
  auth gate, unregister sync triggers, disconnect, clear caches, delete
  the user DB — and flush()/syncAll() bail when the gate is closed.

* fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)

Blob cache previously leaked forever: clearTripData omitted it, entries had
no trip discriminator, and there was no size/count bound, so file blobs
survived trip eviction and could starve the map-tile cache for quota.

- BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a
  backfill upgrade (legacy rows -> tripId -1, bytes from blob.size)
- clearTripData purges the trip's blobs in-transaction
- enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB
- tripSyncManager threads tripId/bytes into puts and enforces the budget

* fix(repo): fall back to Dexie when a network read fails (H2) (#1179)

Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.

- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
  and on a network-level failure (Axios error with no HTTP response). A genuine
  HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
  error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
  coarse global flag and one failed health check would otherwise divert every
  read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
  go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow

* fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180)

loadTrip only replaced the first slice group, so budget/reservations/files
from a previous trip stayed visible after switching trips (data exposure on a
shared screen). Those three also loaded via separate tab-gated effects, so they
never hydrated offline for an unopened tab.

- resetTrip() clears every trip-scoped slice (keeps global tags/categories) and
  runs at the top of loadTrip, so a switch can't leak the prior trip's data
- loadTrip now hydrates budget/reservations/files through their repos alongside
  the rest (non-fatal catches), making offline hydration uniform
- useTripPlanner drops the redundant loadFiles + reservations/budget effects;
  tab-gated lazy reloads stay as on-demand refresh
- tests: cross-trip no-leak, uniform hydration, resetTrip

* fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)

setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie
re-seeded but the open trip's Zustand store was never refreshed — a
collaborator's edits made while we were offline didn't appear until navigating
away and back.

- new tripStore.hydrateActiveTrip(): silent refresh of the active trip's
  collaborative state (days/places/packing/todo/budget/reservations/files),
  no resetTrip and no isLoading toggle so there's no splash on reconnect
- syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook
  first) and re-hydrates open trips after the online-event syncAll; cleared on
  unregister
- websocket exposes getActiveTrips() for the online-event path
- tests: refetch wiring + ordering, silent hydrate without reset/splash

* fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182)

The nightly cleanup deleted idempotency keys older than 24h. The TREK client
replays queued mutations with their X-Idempotency-Key on reconnect, so a device
offline longer than a day had its keys GC'd before it returned — the replayed
POST was then treated as new and created a duplicate.

- raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via
  IDEMPOTENCY_TTL_SECONDS
- extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups)
  with an injectable db, and have the cron job call it
- tests: 30-day default eviction, 25-day key retained (was dropped at 24h),
  env override

H7 (exactly-once across the lost-response window) is deferred: a correct fix
must store the response in the same DB transaction as the entity write. Doing
it in the generic interceptor (reserve-before-handler) cannot store the real
response body for the crash case, which would break the client's temp->real id
remapping on replay (mutationQueue.flush relies on the entity in the body). It
needs a per-service change and is tracked separately.

* fix(realtime): correct assignment:created echo dedup (H11) (#1183)

When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created
dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment
of a place already on the day was silently dropped, and (2) the temp-version
reconciliation matched place?.id === placeId, letting undefined === undefined
collapse place-less rows onto each other.

- dedup now keys on assignment id (exact-id duplicate -> no-op)
- temp (negative-id) optimistic rows are reconciled only when a real placeId
  matches, replacing just that row; a sibling temp of another place is untouched
- everything else appends, including a genuine 2nd assignment of the same place
- tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less
  rows don't collapse

Note: the broader own-echo suppression relies on X-Socket-Id being sent; this
fixes the client-side fallback when an echo slips through.

* fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)

H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.

H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).

- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)

* ci(security): only fail Docker Scout on fixable CVEs

Add only-fixed so the scan no longer fails on vulnerabilities with no
upstream fix available (e.g. base-image OS packages), and only flags
actionable, fixable findings.

* build(docker): rebuild gosu with a current Go toolchain

Debian's apt gosu ships an old Go stdlib that the image CVE scan flags
(1 critical + several high, all in golang/stdlib). Build gosu from source
with a current Go toolchain and copy the static binary in instead; the
runtime behaviour is unchanged — gosu still drops root to node at startup.

* build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr)

The production image's last image-scan finding was esbuild 0.28.0, pulled
in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0
range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change.

* feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)

Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.

Register/demo logins keep their current persistent behaviour.

* chore(ssrf): include lookup error code in error message

* fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197)

* fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193)

Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because
/app/server/uploads is a symlink to the mounted /app/uploads volume and
cpSync (dereference:false) refuses to overwrite the symlink node with a
directory. The DB was swapped before this failing copy, so users saw
restored data but missing upload files (trip covers). Resolve the symlink
with realpathSync before copying so the merge targets the real directory;
no-op on a plain dir, so non-Docker behavior is unchanged.

Also bundle the at-rest encryption key (data/.encryption_key) into the
backup so a restore onto a different install can decrypt stored secrets
(API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via
env (the file is not the source of truth then). On restore the key is
swapped back if the archive carries one; a restart is required for the
in-memory key to take effect.

* fix(docker): fail fast when a volume shadows /app (#1193)

Mounting an old volume at /app hides the image's node_modules and dist,
so startup crashed with a cryptic "Cannot find module
'tsconfig-paths/register'". Add a CMD preflight that detects the missing
app files and exits with actionable guidance. Document in the README that
only /app/data and /app/uploads should be mounted, never /app.

* fix: ssrf test

* fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198)

Clicking an auto-suggest dropdown item did a second /maps/details lookup
that could fail (details kill-switch off, an overloaded OSM Overpass mirror
behind a proxy, or any upstream error), dead-ending on "Place search failed"
while the search button stayed reliable.

handleSelectSuggestion now treats a missing or coordinate-less details result
(or a thrown error) as a miss and falls back to the text-search path the search
button uses, applying the first result. The error toast only fires if the
fallback also returns nothing. Adds tests for the previously untested
suggestion-click path.

* fix(planner): scroll long place description/notes on mobile (#1195) (#1199)

The place details card (PlaceInspector) clipped long description/notes
with no way to scroll. The content area is a flex column whose children
(description/notes) had the default flex-shrink: 1, so once the card hit
its maxHeight cap they compressed to fit and their overflow:hidden clipped
the text instead of overflowing into a scroll region.

- Make the content area a bounded scroll region (flex: 1 1 auto,
  minHeight: 0, overflowY: auto, momentum + overscroll containment).
- Pin description/notes with flexShrink: 0 so they keep natural height and
  the card overflows into the scroll instead of clipping.
- Pin header/footer with flexShrink: 0 so they stay fixed while scrolling.
- Add wordBreak/overflowWrap to the description div to fix horizontal clip.

* Day plan: hotel travel times at start/end + login toggle polish (#1206)

* fix(login): use the shared toggle for the stay-signed-in option

* feat(planner): show hotel travel times at the start and end of a day

* fix(login): give the stay-signed-in toggle an accessible name and fix its test

* fix(trips): keep the day-count field empty when cleared and validate it (#1204) (#1207)

* docs(readme): refresh dashboard, costs and trip screenshots (#1208)

* docs(readme): refresh dashboard, costs and trip screenshots

* docs(readme): correct outdated info (React 19, NestJS, 20 languages, Costs rename, passkeys, AirTrail, notifications)

* chore: update all dependencies (#1209)

* chore: update all dependencies

* chore: remove lint errors

* fix(client): restore typecheck after dependency bump

vitest 4 types vi.fn() as Mock<Procedure | Constructable>, which no
longer assigns to the strictly-typed onUpdate prop; type the mock
explicitly. TS6 + the new transitive @types/node 25 stopped auto-
including node builtin module types, so import('node:buffer') failed;
add @types/node as a direct client devDependency and a scoped node
type reference in the one test that needs it.

* test: fix constructor mocks for vitest 4 Reflect.construct semantics

vitest 4 resolves new-invoked mocks via Reflect.construct, which rejects
arrow-function implementations (including mockReturnValue sugar) as
non-constructable. Convert mapbox-gl and better-sqlite3 mocks that the
code instantiates with new to regular function implementations.

* fix(planner): only route to multi-day transport endpoints on their pickup/drop-off days (#1210) (#1212)

* chore: move to Frankfurter API for exchange rate (#1214)

* Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213)

* fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump

* fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4)

* test(nest): cover controller/service branches to clear the 80% coverage gate

* fix(planner): correct transfer-day hotel legs and connect them to transports (#1215)

When you change hotels on a day, the morning bookend leg showed the hotel
you check into instead of the one you slept in whenever the morning stay
didn't end exactly on that day — both bookends collapsed onto the arriving
hotel. The morning hotel is now picked by "checked in earlier and still in
range" rather than "checks out today", which also fixes the route
optimizer's start anchor for the same case.

The bookend legs now connect to the first/last located waypoint of the day
— a place or a transport endpoint (a car return, a taxi or train arrival) —
so the hotel-to-transport drives are included too.

* feat(transports): add kitinerary import-from-file button to Transports tab

* docs(config): document SESSION_DURATION_REMEMBER across deployment artifacts

Add SESSION_DURATION_REMEMBER to docker-compose, .env.example, README env
table, Helm chart (values + configmap passthrough), the Unraid template, and
the Unraid install guide. Where the base SESSION_DURATION was also absent
(README, charts, Unraid) add the pair so the Remember-me variable has context.

---------

Co-authored-by: gzor <risenbrowser@web.de>
Co-authored-by: ppuassi <34529179+ppuassi@users.noreply.github.com>
Co-authored-by: sss3978 <106522699+soma3978@users.noreply.github.com>
Co-authored-by: SkyLostTR <onurluerin@gmail.com>
Co-authored-by: Julien G. <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Dimitris Kafetzis <39215021+Dkafetzis@users.noreply.github.com>
Co-authored-by: Ahmet Yılmaz <70577707+sharkpaw@users.noreply.github.com>
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: jufy111 <40817638+jufy111@users.noreply.github.com>
Co-authored-by: Larinel <bodink7@gmail.com>
Co-authored-by: rossanorbr <48014819+rossanorbr@users.noreply.github.com>
2026-06-16 22:22:45 +02:00