mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
5c8decd3ea986b1b8d8382022e5b0306eb7ccd24
71 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5c8decd3ea | refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork | ||
|
|
845320e0a4 | refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork | ||
|
|
cb177ce655 | refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork | ||
|
|
37992de7ab | refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork | ||
|
|
6b755314d8 | refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork | ||
|
|
7940b7ff84 | refactor(auth, files): async DB-touching methods; transactions through UnitOfWork | ||
|
|
e413308227 | refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork | ||
|
|
aa587e2fb6 | refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork | ||
|
|
953e2526bb | refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork | ||
|
|
0cf99a9939 | refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork | ||
|
|
c8d4ed896e |
fix(trips): let a trip run past 365 days and refuse a longer range instead of clipping it
A trip longer than a year stopped at day 365: generateDays capped the day rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last days had a date but no day to plan on, in the day list, the transport and booking dialogs and the phone's date pickers (#2403). The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the server, both trip forms and the MCP tools, and a range past it is refused rather than cut short: 400 on REST, an error result on MCP, bad params on the plugin RPC, and a message in the forms before anything is sent. The check runs before the trip row and the budget are touched, also catches a start date moved past the stored end on its own, and holds a day_count rebuild to the same bound. A trip that already carries a longer range can still be renamed. The MCP-only 90-day ceiling on create_trip goes with it; it clipped dated trips the same way. Closes #2403 |
||
|
|
4eaf90fd1b |
fix(journey): a place kept across two days is two days of the journal
The sync engine keyed a skeleton by source_place_id alone, so a stop standing on two days — the city you land in at dusk and walk through the next morning — got one entry on the first of them and nothing on the rest. Key on the day assignment instead. The assignment row rather than the day, because moving a stop updates day_assignments.day_id in place: keyed this way an entry follows the move instead of reading as one stop leaving the plan and another arriving. Existing rows are backfilled to the place's earliest assignment, and reconciliation adopts an unlinked row rather than annotating it out from under its author. Fixes #2329 |
||
|
|
54e5525568 |
fix(accommodations): a booking that moves carries its stop instead of rebuilding it
Correcting a booking's date deleted the day stop it had mirrored and inserted a fresh one. A day stop is not just a day and a place: its participants and any road-trip day boundary hang off its id by ON DELETE CASCADE, and its note, its hour and its end-of-day flag live in its own columns. All of it went, silently, for an edit that only meant to move the night one day on. The row is carried across now, seated by its check-in the same way an insert would be, and the delete-and-insert is kept only for the case where the target day already holds that place under a stop of the traveller's own. Three more holes in the same mirror, found alongside it: Dropping a night while keeping the place cleared accommodation_id server side and told nobody. Days hides a stop that carries a booking id, so on every other screen the place stayed invisible on a day it was standing on. A night is seated by its check-in, which renumbers the stops around it, and nothing said so. The day that changed now sends its order along. Deleting a place cancelled the nights booked there, which takes each night's partner booking and that booking's expense with it, and announced neither. The Bookings list kept the reservation and the Costs total kept the money until a reload, and an expense linked by reservation_id is not one the place-linked sweep finds. Also: day_assignments.accommodation_id went in as a bare INTEGER, so shortening a trip past a booking's last night cascaded the booking away and left the stop pointing at nothing, hidden from Days for good while the route still drove to it. Migration 234 clears the ones already out there and adds a trigger, which SQLite fires even when the booking went down with a cascade. And a duplicated trip now carries the stop's booking id, the booking's check-in window and a place's fill_percent, none of which the copy took along. |
||
|
|
43a6c30325 |
feat(places): remember the Amap id a place was found by
The provider produced amap_poi_id, but nothing stored it, so a saved Amap place could never be reopened against Amap. The column now travels with google_place_id and osm_id through the shared contract, the place insert and update, duplicate matching, the assignment projections, the trip copy and the three place MCP tools. |
||
|
|
b773faff70 | feat(roadtrip): share trip preferences and improve service stops | ||
|
|
491cfccc56 | feat(roadtrip): add daily scheduling and configurable day endings | ||
|
|
1e1dd9b814 |
fix(roadtrip): keep the shaping a traveller did, through every path that moves a stop
A via records which stop it follows by POSITION, so anything that renumbers a day's stops moves the ground under it. The road-trip rail corrects for that; almost nothing else did. reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0 in each, and re-anchoring MERGES two legs whenever the stop between them goes away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and forward again. Persisted, surviving a reload, and unrepairable except by deleting the vias. The old anchor is read before the updates and used as the primary sort key, so the vias of the earlier leg stay ahead of the ones that merged into it. Reordering the only two stops of a day deleted every via on it. A reorder is modelled as remove-then-insert, and the removal half refuses to treat a two-stop day as still having a drive — right when a stop really goes away, catastrophic when nothing was removed. Two stops is exactly the shape "follow this track" produces, up to nine vias on that one leg, gone to a single drag with no prompt and no undo. Removing a stop, reordering within a day, and assigning a place mid-day never re-anchored at all, though the helpers exist and three other paths use them. The detour then sat on a leg the traveller never chose, or on a leg that no longer existed and vanished — until a later stop brought it back bending a road nobody asked for. Reordering hands a whole new ordering rather than one move, so the anchors there follow the stop they were pinned behind instead of arithmetic on the index, which is correct for any permutation. Duplicating a trip dropped all of it: `stop_type` was missing from the place insert, and neither via nor day-track table was copied. The duplicate looked complete and quietly drove somewhere else. A failed read of the via list emptied it. That read runs after every write and feeds the routing key, so a 502, a dropped connection or a tab going offline re-routed the whole trip along the roads the traveller had steered away from, with nothing said and nothing retried — indistinguishable from having deleted them. Only a 404 or 403 empties now; anything else keeps the last known list and says it may be stale. |
||
|
|
ffb6546e51 |
fix(mcp): prompts take the trip id as the string every client sends (#2207)
Prompt arguments cross the wire as strings, but the three trip prompts declared tripId as z.number(), so every prompts/get failed with -32602 "expected number, received string", MCP Inspector included. The id is now parsed from the string through a shared tripIdPromptArg (still an integer in the safe range), PromptOptions only admits string-input schemas so the mistake cannot come back unnoticed, and a prompt declared with an empty argsSchema no longer demands an empty arguments object from the client. The prompt tests run through a real in-memory MCP client instead of calling the callbacks directly. |
||
|
|
c59425947a | fix(server): unicode-safe content-disposition for gpx, ics, feed and wallet downloads (#2165) | ||
|
|
15588d20c9 |
fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the confirmation number into the DESCRIPTION, and the shared-trip payload does a SELECT * over reservations with share_bookings on by default. Neither filters anything, so whatever lands on a trip is published the moment it is written. That is fine while a person types every booking in, and it stops being fine as soon as something writes on their behalf. reservations.ingest_state is that gate. It defaults to live, SQLite backfills every existing row on the ALTER, and no current writer names the column, so every booking visible today stays visible. Nothing writes staged yet. The predicate takes its table alias because share.service.ts queried reservations without one. A stay is withheld only when bookings point at it and all of them are staged: a hand-added hotel has no booking at all and has to keep showing up. |
||
|
|
44c2573abb |
feat(mcp): trip length, reminders, cover search, and the member permission
A dateless trip made by a tool silently got seven days, and reminder_days was stuck on the default of three with no way to turn it off. Cover images had no search at all. add_trip_member and remove_trip_member hardcoded an owner check while REST consults the configurable permission matrix and the plugin RPC does too: three surfaces, two behaviours. They now use the same permission call update_trip already made, so an instance that lowered member_manage behaves consistently. The guest tools stay owner-only, because their REST routes are owner-only by design and document it. |
||
|
|
4b21a70ab2 |
feat(mcp): add, rename and delete trip guests
add_trip_member resolves an existing account by username or email, so a travelling companion who never signed up could be put on a trip in the planner and not through an assistant. A guest is assignable to budget splits, packing, to-dos and day participants like any member, so leaving them out of an import loses who was actually there. Mirrors the owner-only /api/trips/:id/guests routes, including the trip-scoped guard that keeps one trip's owner away from another trip's guest and off a real member's user row. |
||
|
|
1ad2e92142 |
fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
|
||
|
|
4bfb277423 |
fix(journey,files): close the cross-trip and role holes on the sharing surface
Files: two :id handlers took the file id from the URL and never resolved it against :tripId. Membership of any trip was enough to read another trip's file links (with the joined reservation titles) or to delete one. Both now resolve the file the way every sibling handler already does. Journey: a contributor with role 'viewer' - blocked from every other write in the domain - could overwrite or delete the Studio book, and could read the share token, which is an anonymous credential that outlives their contributor status. The book writes take canEdit now and the token read takes the owner check its own create and delete already use. Also: the share-link MCP tool reset every flag to permissive defaults when it was called to refresh a link, so a journey deliberately shared without its gallery or map quietly started publishing both; setShareLink dropped newest_first entirely; the public routes ignored the Journey addon switch; and photo uploads committed to storage before the access check, leaking the objects on the 403 path. Trips: DELETE answered 403 to someone with no access at all, which turns sequential ids into an existence oracle. A member who simply lacks trip_delete still gets 403. |
||
|
|
76c8c349b1 |
Fold nest mcp (#2017)
* refactor(server): fold @trek/nest-mcp into server/src/nest-mcp * docs(server): point nest-mcp references at server/src/nest-mcp * remove tracked md file |
||
|
|
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 |
||
|
|
f23de88b74 |
fix(packing): authorize every single-item path against the item, not just the trip
A packing item is Common, Personal or Shared with named recipients. listItems() always applied that rule, but every path that touches one item resolved it by trip id alone, so trip membership plus packing_edit was enough to reach an item the member could not see. Update, delete, clone, the contributor routes and the sharing route all shared the lookup, and the same service methods sit behind the MCP tools and the plugin RPC, so all three transports carried the gap. The update was a read as well: it answered with the enriched row, which hands back the name, category, quantity, weight, the owner's username and the recipient list of an item the caller was never shown. Two flows laundered restricted items into places they do not belong. Saving a trip as a template read every row in the trip, so a template captured the names and categories of everyone's Personal items. Copying a trip did the same and re-inserted without is_private/owner_id, so both fell back to the column defaults and another member's Personal or Shared item reappeared in the copy as a Common item. That path needs only trip_create and trip access, not even packing_edit. There is now one visibility rule in the service and every single-item lookup goes through it. The actor is threaded through the HTTP, MCP and plugin-RPC entry points, and a missing actor denies rather than falling through unfiltered. An item the actor may not see is reported as missing, not as forbidden, so the routes cannot be used to confirm that an id exists. Two deliberate contract changes fall out of that. The sharing route answers 404 instead of 403 when the caller cannot see the item at all; a visible item they do not own still answers 403. And a template or a trip copy now carries the Common list plus the actor's own items, with a restricted item staying restricted and owned by the copier. Reported privately by Chichi (@vickyzer027-hash). |
||
|
|
705c3c08dd |
fix(trips): remap cross-links and copy split rows when duplicating a trip
Copying a trip nulled budget->reservation and reservation->accommodation links (TEXT accommodation_id vs number-keyed map) and dropped budget_item_members/payers, assignment_participants, budget currency/ exchange_rate/expense_date/ticket_json and reservation url. All links now remap to the copied rows; external_* sync identity is intentionally not copied; the to-do reset is now documented (smoke-test I-01). |
||
|
|
1239be4259 | refactor(server): fold the _shared MCP helpers into an injectable guards provider; delete the last bridge | ||
|
|
079064fdcd | refactor(server): delete trips.bridge via leaf membership reads and prompt relocation | ||
|
|
1a945baebe | refactor(server): shrink assignments.bridge to its two live exports | ||
|
|
8b7b91d814 | refactor(server): let user-cleanup inject BudgetService and delete budget.bridge | ||
|
|
8c8cc2a727 |
Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.
The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.
Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.
The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.
Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.
Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
|
||
|
|
49c3c57c31 |
Give the ephemeral tokens a provider, and clear the next wave of type errors
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup timer only because it happened to be the domain that mints tokens. Issuing them and sweeping them are different jobs, and FilesService and TokenService mint or consume them too. EphemeralTokenService owns the lifecycle and hands the three operations out. The store and the interval stay module state next door, and that is load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on the upgrade handshake, outside the container, before any injector is in reach. Per-instance state would hand it an empty Map and close every connection with 4001, which no test would catch because the ws suites drive the transport directly. WS-027 through WS-029 pin the session gate that runs through exactly that path. The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken returns null at 10,000 live tokens and TokenService turns that into a 503. A second store would raise the real ceiling with nobody noticing. Its own leaf module rather than a provider in AuthModule: TokensModule mints download and ws tokens as well, AuthModule already imports TokensModule, and TokensModule is documented as a deliberate leaf that three graphs reach for. Same shape as MailerModule, which broke AuthModule <-> NotificationsModule. Twelve hand-wired construction sites in tests needed the new collaborator. The typecheck named all twelve; before this branch they would have been undefined at runtime. Sixteen more test files are cleared here too, each at its root cause. Four of them were passing for the wrong reason: files.controller.test.ts drove two handlers with shifted arguments, a string tripId where the trip row belongs and undefined where tripId and id belong. It stayed green because FilesService is fully stubbed and the stubs ignore what they are given, so DELETE /:id/permanent and DELETE /trash/empty never covered that the trip row reaches the permission check. conflictUpdate.test.ts was missing real collaborators on PlacesService and PackingService. None of the exercised paths reach them, so it passed by luck. assignments.service.test.ts builds its service by hand, and the one consumer of the collaborator it was missing swallows every throw in a bare catch. A broken journey reconciliation would still have shown 28 green tests. scheduler.test.ts mocks two modules with paths that resolve one directory above the server root, so they mock nothing at all. Harmless only because src/scheduler.ts imports neither. |
||
|
|
6cf5760383 |
Inject the notification senders instead of importing them lazily
Nine fire-and-forget sends reached notifications.bridge through
import('...').then(({ send }) => ...). The laziness was working around a
module cycle that no longer exists, and it cost twice: it hid the edge from
the module graph, and it handed each send a NotificationsService built
outside the container rather than the container's own.
The sweep needed a method rather than a grep. tests/ sits outside
tsconfig's include, so a service that gains a constructor parameter does not
break its hand-wired call sites at compile time — the argument arrives as
undefined and throws inside the send. Adding tests to a throwaway tsconfig
and diffing the errors before and after named all 46 sites exactly. The same
config reports 288 pre-existing errors in the suites, almost all deliberate
partial constructions in the harnesses, which is why tests/ is not in the
build's include; that cleanup is its own piece of work and is written down
in the nest README.
notifications.instance.ts holds the out-of-container instance, apart from
notifications.bridge.ts on purpose: several suites replace the bridge
wholesale with vi.mock(..., () => ({ send })), and a sibling bridge taking
its instance from there would break under those mocks.
The suites that assert on a send now hand their spy to the injected service.
The reservations e2e overrides the provider, because its app resolves the
real one out of its own container and never saw the module mock.
|
||
|
|
2d0eca7938 |
Let MCP addon gates read an injected service
Nine *.mcp.ts files reached through addons.bridge for one boolean each, and platform.routes plus the legacy registrars still do. That was never a preference: a decorator's options object is built while the class is being defined, so the `when:` closure had no `this` to reach an injected service through, and the only thing in scope was a module-level singleton that builds its own AddonsService outside the container. Nine domains hid an edge to the addons domain from the module graph to answer "is this addon on". @trek/nest-mcp hands the gate its declaring instance now, resolved at attach rather than captured at module load, and addons/addon-gate.ts turns that into addonGate(ADDON_IDS.X) over the injected service. The option is declared with method syntax so a predicate written against its own controller class stays assignable. Two more edges went the same way, both checked for cycles first: reservations.mcp injects AssignmentsService (AssignmentsModule never reaches back), atlas.mcp and journey.mcp inject AuthService. The tests that forced a toggle by mocking the bridge module now write the addon row the admin panel writes, so they exercise the real read. Four in-container uses stay, each documented in the nest README: places.mcp (a real Days -> Places -> Assignments cycle), budget/packing/costs against trips.bridge, user-cleanup against budget.bridge, and the two multer interceptor options, whose fix is MulterModule.registerAsync. |
||
|
|
d95aa49c7f |
Split the trip aggregate root into its parts
TripsService injected fourteen other services. Almost all of them were there for two read aggregates - the MCP trip summary and the offline bundle - and for the member roster, not for anything the write path does. Reading is what fans out; writing is not. Membership goes to trip-members/: listMembers, addMember, removeMember, transferOwnership and the four guest methods, plus the six routes, which keep their paths. It is a NEW module, not the trip-membership/ leaf next door: deleting a guest erases their plugin data and re-splits their expenses, so it needs auth and budget, and AuthModule already imports that leaf. Giving it those dependencies there would have closed a hard cycle. This one is a sink - nothing imports it back except trips. The read aggregates go to trip-read-model/, which is where the nine-service fan-out now lives. What is left is the write core plus day generation, on eight parameters instead of fourteen: database, reservations, days, permissions, budget, vacay, realtime, unsplash. The last four are all reachable from updateTrip's date-shift transaction, which is why the count does not fall to three. Trips still reaches budget through ReservationsModule, so trips.bridge.ts survives this. Saying otherwise would have been the easy claim to make and it would not have been true. |
||
|
|
3d522be0e5 |
Give accommodations one home instead of two halves
The routes lived in reservations/ while the SQL lived on DaysService, so the controller delegated every call across a domain boundary to reach its own data, and neither module owned the thing outright. The seven statements move into AccommodationsService unchanged and the domain gets its own directory, module, DTO, RPC and MCP surface. What stays in days/ is the reorder side: assertNoInvertedAccommodation and resyncAccommodationDays are about re-dating day rows, and the stay is the thing being carried, not the thing doing the carrying. Two edges fall out of this. AccommodationsService drops its DaysService dependency, and DaysMcp drops DatabaseService and PlacesService, because create_place_accommodation was their only user - which lets DaysModule stop importing PlacesModule and closes one leg of the latent Days -> Places -> Assignments -> Days cycle. DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with their case ids intact. The reservations e2e container assembles both modules now: it builds a single domain module on purpose, which is exactly what catches a move like this, and it caught it. |
||
|
|
5a9ded6676 |
Move the calendar export off the trip aggregate
exportICS and its four module helpers were 330 lines of RFC 5545 inside TripsService, and FeedsService had to import the whole trip aggregate to get an ICS string out of it. They move to a calendar domain unchanged - same statements, same escaping, same folding, same VTIMEZONE fallback - so the emitted bytes are identical on both the download and the feed. The 21 unit cases move with the code and keep every assertion, which is what makes that claim checkable rather than asserted. FeedsModule now imports CalendarModule instead of TripsModule, and its e2e container drops from the trip aggregate to two small modules. NotFoundError/ValidationError move to common/ so a second domain can raise them without importing the first one for its error types; trips re-exports them, so the nine files that import them from there are untouched. Fixes a harness that tsc cannot see on the way past: mcp-test-controllers built TripsService with 12 arguments for 14 parameters, leaving unsplash and userCleanup undefined. |
||
|
|
611f94e820 |
Consolidate the schema-error fallback and cover the defensive branches
Aiming the new files at 95% rather than at the 80% gate turned up something worth fixing rather than testing around: `error.issues[0]?.message ?? 'bad input'` had been copied into twenty handlers. That is twenty two-branch fallbacks that no test can reach, because a real Zod failure always carries at least one issue. They are now one schemaMessage() in rpc-params.ts, with one test that does reach both sides. The rest of this is the branches the handlers inherited from the router: the `!= null` link ternaries, the "service said no" paths, the ValidationError and NotFoundError translations, and a reject case per schema-validated method. Two limits found while measuring, both real rather than laziness. istanbul counts each @PluginController line as a branch and only ever takes one side, so eight branches across the rpc files can never be covered and a two-method file caps out around 50%. And the three update schemas stay deliberately permissive, because the migration's rule is parity over tightening, so their reject branch is defence in depth no payload reaches. Everything else went from 50 uncovered branches to 29. |
||
|
|
010242b107 |
Move costs, reservations and accommodations onto the decorators
Eleven more methods, so the registry owns 61 of 113. rpc-host.ts is at 771 lines,
down from 1332.
costs is the first addon-gated domain, and the addon check sits in a different
place in each of the two reads: inside the membership callback for getByTrip, so a
caller without trip access never learns whether the addon is on, and before
everything for listMine, which has no trip to check. Both orderings are asserted.
The writes keep their own refusal wording ("no permission to edit costs on trip N")
instead of going through requireTripEdit, which words it differently.
The booking paths are cascade-heavy and stay that way: creating a reservation can
create an accommodation and a budget item, deleting one removes both again, and
deleting a lodging block takes its partner reservation and budget item with it.
Each cascade is a broadcast the web app relies on, so they are asserted by event
name and order.
CostsRpc reaches the trip list through trips.bridge rather than by injection.
BudgetModule cannot import TripsModule, because TripsModule already imports
BudgetModule; budget.mcp.ts uses the same escape for the same reason.
|
||
|
|
2ec6d006b0 |
Move the trip surface onto the decorators
Twelve more methods, so the registry owns 50 of 113. rpc-host.ts is down from 1332 lines to 830 and the deps factory from 944 to 500. trips.update keeps its two field-level gates: archiving and changing the cover each need their own admin-configurable permission on top of trip_edit, so a plugin cannot do either on a trip it may otherwise edit. Member management keeps its own grant for the same kind of reason, since adding a member grants trip access, and the owner still cannot be removed through it because that would orphan the trip. canEditTrip stays in HostDeps for now: the db:meta gate still resolves an entity's edit permission through it and has not moved yet. |
||
|
|
f0f69df67e |
Fold the journey domain into Nest and finish the MCP migration
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become JourneyDomainService and JourneyShareService. Every SQL statement, error string and broadcast is the one that shipped. The two could never be split: the share service gates on isOwner, which lives in the domain service, so it injects it. JourneyDomainModule is a leaf carrying just the two services. Places, assignments and the plugin host each need a handful of journey functions; importing JourneyModule for that would drag MemoriesModule and both photo providers into their graphs. Same shape as TrekPhotosModule. The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is down to _shared.ts and registerTools() no longer fans out to anything. journey.bridge.ts keeps four exports, for the journey resources in src/mcp/resources.ts that register at import time. McpAccessMode is host-augmentable now. journey:share is neither read nor write, so the three share tools would have had to be opaque predicates - and predicates bypass the access policy entirely, invisible to the boot-time scope gate. A scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'. Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed PlacesService with five of eight arguments. tsconfig only includes src, so nothing typechecked it, and the missing dependencies were harmless until the journey skeleton hooks landed on the place write paths. journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25 cases for the skeleton lifecycle, the photo gallery and the preference row take the domain service from 73.9% to 82.8%. |
||
|
|
99ee307034 |
feat(server): expose the active trip as its own endpoint
GET /api/trips/active answers with the trip a user means by "my trip" right now: the one running today, else the next one starting, else the most recently started. Archived trips never qualify. That order is the dashboard hero's (sortTrips), on purpose - the startup redirect this is for has to land on the same trip the dashboard features, or the two would disagree about which trip is yours. Kept apart from list() because it runs on a first paint: four columns of one row instead of every trip with its per-trip day and place counts. |
||
|
|
03493a2edd |
refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth webauthnConfig resolved the RP ID through its own getSetting() on the db singleton; it is a WebauthnConfigService over DatabaseService now, injected into AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv() is still read per call, so an operator flipping the RP ID at runtime takes effect on the next ceremony instead of after a restart. userCleanupService follows as UserCleanupService. It keeps taking removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService: BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule cannot import BudgetModule back, and breaking that edge the other way drags BudgetController, BudgetMcp and its trips.bridge chain into every graph that imports AuthModule. The legacy module had no tests of its own - the erasure only ever ran through AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009 pin both halves, including the orphan data dir that no permissions row can describe and the all-or-nothing deletion transaction. * refactor(server): fold the oauth 2.1 server out of services/ 694 lines move into OauthService over DatabaseService, AddonsService and AuditService. The thin wrapper that only forwarded is gone, and so is the second path to the MCP addon flag - validateAuthorizeRequest read it through addons.bridge while the wrapper read it through the injected service, which would have let the two drift. The pending authorization codes stay a module singleton in oauth.pending-codes.ts instead of becoming instance state. The SDK still serves /oauth/authorize, /oauth/register and /oauth/consent from its own router, mounted on Express before app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps would break the authorization-code flow silently, since the SDK only ever answers "Authorization grant is invalid.". The import-time sweep keeps its .unref() and is a named function now so a test can drive it. scopes and sessionManager are imported deep, never through the src/mcp barrel: the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and auth.service carry. The 70 cases of the legacy suite moved over unchanged, bound to a hand-built service. Fifteen more cover what free functions never let anyone reach: the code store at capacity, an expired code, an expired access token, refresh against a public and an unknown client, the RFC 8707 resource checks, and that a code written through the container is read back through the bridge. * refactor(server): move the wiki reader into the help module wikiService had exactly one consumer, HelpController, and touches no database - it stays a plain module rather than becoming a provider, the way auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per process without any ceremony around it. The WIKI_DIR anchor needed one more level: src/services sits three below the repo root, src/nest/help sits four. The existing path-resolution case caught it, which is what it is there for - a wrong anchor makes help degrade to the GitHub fallback without anybody noticing. Also brings the nest README back in line: services/ is down to five top-level files, and the passkey and budget entries no longer point at paths that were deleted since they were written. |
||
|
|
c7fed4b1f8 |
refactor(server): make the place-photo cache a provider (#1833)
The last file in be-places-helpers-fold, and the one that had to be a provider rather than a move: it carries three in-memory structures that only do their job if the process has exactly one of them. recentFailures stops a screenful of markers hammering an API that is down inFlight stops a stampede when several requests miss the same placeId knownOnDisk saves an existsSync per cache hit A second instance gives the stampede guard its own inFlight map, so two simultaneous requests for the same uncached place both call the provider. That is why the scheduler takes this one from the container instead of constructing it: SchedulerDeps gains a placePhotos entry alongside backups, filled from app.get() in index.ts — the shape PR #1827 was built for. The module-scope mkdirSync becomes onModuleInit, and GOOGLE_PHOTO_DIR is read per call through RuntimeEnvService rather than frozen at import. That is not cosmetic: TREK_PLACE_PHOTO_DIR is the seam the tests use to keep off the real uploads tree, and its own test used to have to import the module dynamically to set the variable in time. The __dirname fallback moved a level with the file and is corrected accordingly. Consumers: maps, places and share inject it; places.helpers.reclaimPhotoCache takes it as a parameter, since it is a free function. Six test seams follow. Four path mocks become injected stubs with the same mock functions behind them; the collections case keeps the REAL implementation, because what it asserts is that removeIfUnreferenced counts collection_places as a reference (#1081) — a stub there would assert nothing. The share e2e overrides the provider in its container. services/ is down to 14 top-level files, from 44 when this started. |
||
|
|
90e35e6970 |
refactor(server): fold llmConfig and unsplash out of services/ (#1831)
Two of the three leftovers in be-misc-fold. The third (wikiService) needs a Zod contract in @trek/shared first and is left for its own PR. llmConfig.ts was pure movement: 70 lines of encryption-aware config shaping with no state and no database, sitting in services/ while all four of its callers are in src/nest. It is nest/llm-parse/llm-config.ts now, same functions, same file. unsplashService.ts needed an actual provider. Its key resolution reads both the env and the users table (env var, then the caller's stored key, then any admin's), so the injected connection was unavoidable. Its own module because trips and places both reach it, and because folding it into either would have made the other import a trip/place service for an image search. The four exports became methods on UnsplashService. The SSRF guard in saveUnsplashCover is untouched — it still refuses any host but the Unsplash image CDN and still goes through safeFetch — as is the unauthenticated-endpoint fallback when no key resolves (#1449). The env read stays live through RuntimeEnvService rather than a registerAs token. readEnv() is what the function did before, and a boot-frozen token would change when an operator's UNSPLASH_ACCESS_KEY takes effect. Two test seams follow the code: the unsplash unit tests bind the four methods so every existing assertion reads unchanged, and the trips controller's path mock of the module becomes an injected stub. services/ is down to 17 top-level files. |
||
|
|
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. |
||
|
|
939452f9d8 |
refactor(server): consolidate trip access and fold the query helpers into the container (#1824)
* refactor(server): route every trip-access check through DatabaseService
Twelve Nest services carry the same verifyTripAccess/canEdit pair. Six already
asked DatabaseService.canAccessTrip; the other six went through
services/tripAccess, a nine-line re-export of the identical function. Same
query, same result, two ways to reach it.
All twelve now take the injected route, so services/tripAccess is gone.
The bigger duplication was in the types. Thirteen files declared their own
`Trip` for the same return value, in three different shapes:
NonNullable<ReturnType<typeof verifyTripAccess>> 6x
{ user_id: number } 4x
TripAccess 3x
The narrow one is why assignments, days, places and accommodations all wrote
`as Trip | null | undefined` on the call — the cast existed to shrink
TripAccess down to a local type that was never meant to differ from it. All
thirteen now say TripAccess, and the four casts are gone with no other change.
middleware/tripAccess.ts goes too: it was already dead in production — nothing
under src/ imported requireTripAccess or requireTripOwner, only comments in the
services that had absorbed them. Its test went with it.
* refactor(server): fold the query helpers and trip joins into the container
Two more legacy files whose only reason for staying outside the container was
that they predated it.
services/queryHelpers.ts held five exports doing two different jobs. Three are
the batch loaders that keep the list endpoints off N+1 queries — they hit the
database and are now QueryHelpersService over the injected connection, imported
by assignments, days, places and share. The other two, formatAssignmentWithPlace
and ratingAggregate, touch no database at all; they sat next to the loaders only
because the loaders were free functions too. Those stay free functions, in
common/rowShape.ts.
The README note on the assignments service claimed the loaders had to stay in
services/ because the day, share and place services were unmigrated. All three
have since moved, so the reason is gone.
services/tripMembership.ts becomes TripMembershipService in its own module. It
could have folded into auth or trip-invite, but both call it — as do oidc and
the plugin RPC host — and putting it in either would have made AuthModule and
TripInviteModule import each other.
Neither module is @Global, matching PermissionsModule: e2e TestingModules
resolve them transitively through each consumer's explicit import.
Test seams changed shape accordingly. Two path mocks of services/tripMembership
became injected stubs; the trip-invite e2e overrides the provider in the
container instead. The oidc test keeps the real implementation, which is what
OIDC-SVC-045 was always asserting.
ratingAggregate moved out of the ungated services/ tree into the coverage-gated
one, so it gets the direct tests it never had — as does the tags/participants
pass-through, which two long-dead fixture variables in the moved test file had
been waiting for.
Also drops four stale comments that outlived their subject: the requireTripAccess
middleware they pointed at was deleted in the previous commit.
|
||
|
|
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. |