Commit Graph

1719 Commits

Author SHA1 Message Date
Maurice 14d6f2ecc9 Keep the hover card's corners round without its tail 2026-08-10 12:24:15 +02:00
Maurice 2a94a5eb8e Put the add-place dialog on the theme tokens 2026-08-10 12:24:15 +02:00
Maurice e03b6a3080 Match the keyboard button to the date field beside it 2026-08-10 12:24:15 +02:00
Maurice 0d58080fa6 Lay the trip form out in two columns and give it the dashboard's shapes 2026-08-10 12:24:15 +02:00
Maurice a2a9c3094c Drop the tail from the map hover card 2026-08-10 12:24:15 +02:00
Maurice f5a483da64 Get the pictures right and stop reusing stale answers
Three things you could see on dev1.

Pictures were "roughly right" rather than right, because a coordinate search
only knows what was photographed within 300 m. Wikidata's P18 is one picture
somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the
restaurant, not a statue down the road - so it now leads, then the Commons
category, and geosearch is the last resort it should always have been. Small
venues carry a wikidata tag far more often than a Commons category, so this is
also what reaches the places the other sources miss.

Descriptions stayed on Wikipedia after the switch to Wikivoyage because the
week-long result cache predates it. It carries a format version now; a release
that changes the shape or the sources invalidates its own entries instead of
serving last week's for another six days.

Ratings and opening hours were being fetched from Google and then dropped on the
floor - the fact list only looked at OpenStreetMap tags. Both are shown now,
whichever provider supplied them.

Also: the column follows a place opened from the map, not only one picked from
search. A POI tap arrives as prefillCoords and never touched the selection, so
the column kept showing the previously added place.

Pictures are a tight three-column grid and only the picture in play is credited
in full, on one line. Crediting all of them at two lines each drowned the
column, and the licence attaches to the one that gets used - whose credit also
stays visible in the inspector after saving.

The request carries the place record the dialog already fetched, so the server
skips a details lookup it was making in parallel with the client's own. That
lookup was measured at 12.8 s for a large OSM relation.
2026-08-10 12:24:15 +02:00
Maurice d98d597581 Make the detail column useful for places no encyclopaedia describes
A restaurant got nothing: no wiki article, and a coordinate search around it
returns whatever was photographed nearby rather than the place itself. Three
changes, all on free sources:

OpenStreetMap facts. A tagged restaurant carries its cuisine, its opening hours,
a link to its menu, outdoor seating, step-free access and diet tags. Those
arrive with the details lookup the dialog already makes, so showing them as
chips costs nothing and is the single biggest gain for exactly the places that
were empty before. Only positive facts are listed - "no outdoor seating" is
noise.

Commons categories. Where a place has a wikimedia_commons tag, its category is
pictures OF it; geosearch falls back in when the category is empty.

Wikivoyage before Wikipedia. Same API, same licence, but it describes a place
for someone about to visit rather than opening with area in square kilometres.
Two sentences instead of three, because this sits beside a form.

The column also follows the section-heading idiom Vacay and the dashboard share
(uppercase overline, wide tracking), its header matches the Saved places panel
beside it, and a lead picture with a row underneath replaces the two-column
grid that left a hole on every odd count. Credits wrap over two lines instead
of truncating - a cut-off name credits nobody.

Saved places stops after ten with a Show more button; search and filters still
run over everything, not just the visible page.

Form spacing is one rhythm (12px between groups, 8px inside, 4px label to
field) and Notes matches Description in height. The OSM notice fits on one line
and no longer claims there are no photos, which stopped being true when the
column started fetching them from Wikimedia. The clipboard hint is gone; paste
still works.
2026-08-10 12:24:15 +02:00
Maurice fc9ac64496 Rebuild the enrichment cache when its pictures were swept
The nightly photo sweep deletes every candidate nobody picked, which is most of
them. The cached result still pointed at those files, and the read filtered them
out - so the morning after, a place with perfectly available pictures showed an
empty column, and kept showing one for the rest of the week the entry lives.

A missing candidate now invalidates the whole entry instead of shrinking it.
2026-08-10 12:24:15 +02:00
Maurice 1f1ed630fc Fetch the enrichment pictures concurrently
The column showed "details could not be loaded" for every place. The endpoint
was answering correctly, just after ten seconds, and the shared axios instance
gives up at eight.

The downloads sat in a loop: five Commons hits meant five round trips and five
decodes in sequence, even though the slot limiter already allows five at once.
Both provider listings now run together and the bytes are fetched concurrently,
which brings a warm request back to about a second.

Candidate keys move with it. They now number the provider's list rather than the
surviving strip, so a picture that fails to download leaves a gap instead of
renumbering the ones behind it - a key has to point at the same file on the next
request or the cached result goes stale against the photo cache.

The client asks for enrichment with a longer timeout, and the Wikimedia calls
carry one of their own: a cold TLS handshake from a fresh container was measured
at eight seconds on its own, and no pictures is a better answer than a request
that never comes back.
2026-08-10 12:24:15 +02:00
Maurice b36103d2a9 Document where place pictures and descriptions come from
NOTICE knew about geoBoundaries, OSM and OurAirports but not about Wikimedia,
Wikipedia or Google Places, all of which TREK has been calling for a while.

Also fixes two dead paths in the place contract - it still pointed at
server/src/routes/places.ts and services/placeService.ts, both long gone.
2026-08-10 12:24:15 +02:00
Maurice 5d99886b04 Keep a picked place image and its credit visible everywhere
Accommodation cards read the place from page-local state, so a new thumbnail
only showed up there after a reload. place:updated now asks the planner to
reload them, but only when the image actually changed - renaming a place must
not trigger a refetch.

The inspector shows the stored credit under the avatar. Crediting a CC BY-SA
picture only in the moment it is chosen is not enough; the obligation travels
with the picture.

Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension
check did not, so one silently fell back to the category icon.
2026-08-10 12:24:15 +02:00
Maurice 4843ebc937 Show place details beside the search field
Picking a search result fills a column on the left of the add-place dialog -
pictures and a description, no extra click. Someone who already knows the place
ignores it and saves exactly as before.

The column stays mounted with an empty state instead of appearing on the first
pick, otherwise the dialog jumps sideways mid-typing. Requests are cached in
memory and in sessionStorage and aborted when the selection changes, so clicking
back and forth between two results does not pay twice.

Every thumbnail carries its author, its licence and a link to the file page.
That is not decoration - most of these are CC BY-SA, and reusing one without
naming the author does not satisfy the licence. The description is shown but
never written into the place on its own; a button copies it across, and it is
disabled while the user already has a description of their own.

Desktop only. The mobile sheet is untouched.
2026-08-10 12:24:15 +02:00
Maurice f2368f86cf Add an admin switch for place enrichment
Reads fail-open, unlike the three older places_* switches. Those read
=== 'true', which is why migration 185 had to backfill a row on every existing
install; there is nothing here that warrants repeating that.

The three hand-rolled toggles next to it move to ToggleSwitch while I am in
there - they were bare buttons with an inline transform and no aria-pressed or
label, so a screen reader could not tell their state.
2026-08-10 12:24:15 +02:00
Maurice e9f9cfb5d5 Add the place enrichment endpoint
Fans the new provider primitives out into one answer for a place that is being
looked at but not saved yet: a strip of pictures and a description, each with
its source.

Free sources are the normal case - most instances have no Google key at all, so
Commons and the OSM tags have to carry this on their own and Google is what an
admin adds on top. Pictures are copied into the existing photo cache under
"<placeId>~pN" keys rather than hotlinked: Google's photo URLs expire (migration
107 had to repair exactly that once), and a provider URL in the strip would send
every viewer's address to Google or Wikimedia. Whatever nobody picks is
unreferenced and the nightly sweep clears it out.

The result is cached for a week in place_details_cache under a third expanded
value, so no migration. Rate limited per user, and a provider being down answers
empty rather than failing the dialog.
2026-08-10 12:24:15 +02:00
Maurice 23e76cc3ec Let the maps providers hand back candidates, not just one picture
Commons geosearch already returned five files and we threw four away, so a
whole strip costs nothing extra. It now comes back with the licence metadata
too: without LicenseShortName and the file description page there is no way to
credit a CC BY-SA image, and a picker full of them needs that.

Wikipedia descriptions resolve through the OSM wikipedia tag rather than
guessing the article from the place name, which lands on the wrong article for
anything ambiguous. Google photo references and photo bytes are separate calls
now so a caller can list without downloading, and the editorial summary has its
own lean field mask - asking for it through the expanded details would drag
reviews along and move the call into the pricier tier.
2026-08-10 12:24:15 +02:00
Maurice 4cd50e6ed3 Add a contract for place enrichment 2026-08-10 12:24:15 +02:00
Maurice c674df72a2 Keep the atlas controller test free of the MCP SDK chain
Asserting the module registration meant importing AtlasModule, which pulls
AtlasMcp and the whole MCP SDK behind it into a file that otherwise
constructs a controller with a stub. It does not resolve when the file runs
on its own.

PROFILE-014 already covers the registration, and covers it better: it calls
GET /api/auth/travel-stats through the full app.
2026-08-09 22:43:05 +02:00
Maurice accd806786 Give the travel-stats cases a file of their own
atlas.service.test.ts carries the admin1 GeoJSON bundle warm-up from #48, and
its ATLAS-UNIT-021..024 cases sit close to the 15s limit whenever the machine
is busy. Eighteen more cases in the same file is not what they need.

Same DB-per-file pattern, no shared state, case IDs unchanged.
2026-08-09 22:43:05 +02:00
Maurice 9895c7112a Give the admin OAuth-session panel back to the oauth domain
The last of AdminService's borrowed SQL. oauth_tokens belongs to oauth/,
which already had a user-facing view of the same table; AdminService carried
a second one only because the panel route is under /api/admin.

Both survive, under names that say which is which. They are not duplicates:
the user-facing listOAuthSessions is scoped to one user, the admin one spans
all of them and joins the owner username, and the admin revoke kills the
client session for whoever owned the token.

OauthModule gains its first export. Its header said nothing inside the
container injects the domain, which stops being true here.
2026-08-09 22:43:05 +02:00
Maurice 93bbc65520 Return the registration invites to the domain that consumes them
invite_tokens gates signup. Its consumer is the registration path in
AuthService, not anything in admin/, which held the four management methods
only because the routes sit under /api/admin.

Worth spelling out because the names invite it: invite_tokens and
trip_invite_tokens are different tables for different jobs, and this one has
nothing to do with adding a member to a trip. An earlier reading of the plan
had these going to trip-invite/, which would have been the wrong home.

The routes keep their paths and their admin guard; AdminController injects
the service instead of carrying another domain's SQL.

Also recorded, in the file, why the app-settings pair did NOT move to
settings/ with the other app_settings readers: updateAppSettings runs the
lockout guard that stops an admin disabling every login method, and that
guard is auth's own resolveAuthToggles. Moving it would put an AuthModule
import inside a leaf module half the container pulls in.
2026-08-09 22:43:05 +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 8a09f55571 Give three more admin surfaces back to the domains that own them
default-user-settings, notification-preferences and oidc leave AdminController.
The first two were pure pass-throughs on AdminService that did nothing but
forward and cast; the third had its SQL there for a domain that already had a
module of its own. AdminService loses SettingsService and
NotificationPreferencesService entirely.

Two things came out of the move rather than going into it.

The OIDC lockout guard now has tests. It refuses to clear the SSO config while
password login is disabled — otherwise the instance has no SSO to log in through
and no password form either — and nothing covered that branch before, because it
sat among admin tests that only exercised the happy path.

The admin e2e app had to learn about the split. It assembles AdminModule alone,
so the moved routes 404'd there while working in production; it now imports the
three domains too. That is the same failure mode the module-provider guards
catch for RPC classes, and it is worth knowing that an e2e app which builds a
subset will find it first.

Also fixes the audit route note: audit-log stays on AdminController on purpose.
AuthModule imports AuditModule, so an admin-gated controller in AuditModule
closes a cycle — the same trap the addons toggles hit.
2026-08-09 20:54:39 +02:00
Maurice d4a1601855 Give the packing templates and the feature flags back to their domains
Twelve admin packing-template routes sat on AdminController and reached
PackingService through eleven pass-through methods on AdminService that did
nothing but forward and cast. PackingService already owns all three template
tables, so the routes move to AdminPackingTemplatesController next to it and the
pass-through block is gone. AdminService no longer injects PackingService at all.

The three places flags were raw app_settings SQL on AdminService, sitting beside
bag-tracking and collab-features which it already delegated to AddonsService.
They move to AddonsService, which owns every other flag, and AdminController now
calls it directly rather than through delegating methods.

The toggle ROUTES stay on AdminController, which is a departure from the plan and
worth stating: an admin-gated controller in AddonsModule makes that module import
AuthModule, and PluginGuardsModule imports AddonsModule precisely because it is a
leaf — Nest refused to build the graph. Same for the addon CRUD, whose PUT
cascades into PluginRuntimeService. Both notes are in the code.

Paths, response shapes, the {error,status} envelope, the create-201 split and
every audit action are unchanged.
2026-08-09 20:54:39 +02:00
Maurice 94221a6d91 Roll TripAccessGuard onto the remaining trip-scoped controllers
assignments, budget, collab, accommodations, reservations and booking-import
drop their copied requireTrip/requireEdit helpers for the guard. Status codes and
bodies are unchanged.

places is deliberately NOT converted, and that is the finding of this PR rather
than an omission. Its e2e suite documents that the body pipe answers 400 ahead of
the trip 404 — a parity shift somebody made on purpose — and a Nest guard always
runs before the pipe, so converting it silently reverses that ordering. The first
attempt also dropped the permission check on DELETE, which the integration suite
caught as a 200 where a 403 belonged. places keeps its in-handler check, and
README now says why so the next person does not rediscover it the same way.

collab applies the guard per handler for the same reason files does: its note-file
upload route would otherwise be refused before multer had read the body, and a
response sent mid-upload destroys the socket.

Two coverage notes. The per-domain thresholds are regenerated because deleting a
well-covered helper lowers a file's ratio without anything becoming less tested —
the drops are 0.4 to 1.2 points across six domains and match the helpers removed.
And the atlas integration suite gets the same admin1-bundle warm-up the unit suite
got: ATLAS-013 was paying 3.1s for the one-off decompression inside its own 5s
budget, which is why it only ever failed under load. It now runs in 116ms.
2026-08-09 19:46:00 +02:00
Maurice 8c0280e287 Replace the copied requireTrip helpers with a TripAccessGuard
Every trip-scoped controller carried its own three-line requireTrip, and a
requireEdit next to it: eighteen copies of a security check, each of which had to
remember that no access is a 404 'Trip not found' and never a 403 — the
distinction that stops a stranger learning which trip ids exist. One of them
getting it wrong would have looked like nothing.

TripAccessGuard resolves :tripId once, refuses what the user cannot reach and
hands the row to the handler through a @Trip() param decorator (a param
decorator rather than a req.trip cast, because the request type that used to be
widened for that is gone). @RequirePermission carries the same action string the
domain services pass to checkPermission, so the HTTP and MCP paths cannot drift
apart on which right they demand. Status codes and bodies are unchanged.

Piloted on days, day-notes, todo, packing and files. Two things worth knowing:

Files applies the guard PER HANDLER and leaves the upload route on its own
check. Guards run before interceptors, so a class-level guard would answer 404
before multer had read the multipart body, and a response sent while the client
is still streaming gets the socket destroyed — the caller sees ECONNRESET
instead of the 404. Refusing after the body has been read costs the bandwidth
and keeps the answer readable.

The services keep verifyTripAccess and canEdit: the large majority of their
callers are *.mcp.ts tools, which never pass through an HTTP guard. canEdit lost
its incidental controller-test coverage in the move, so it now has direct tests
asserting the action and the shared flag — the same pair the guard reproduces.

Also fixes the backup zip-slip test that failed only on CI: it asserted a
drive-letter path, which is absolute on win32 and an ordinary relative segment on
Linux, so the branch it was guarding never fired there.
2026-08-09 19:13:21 +02:00
Maurice fef338091c Fold backupService into the backup domain
src/services/backupService.ts moves to src/nest/backup/backup.impl.ts, imported
only by its own domain now that the controller reaches it through BackupService.
src/services/ is down to airtrail/.

It stays a module of free functions rather than becoming methods on the service.
The restore path closes and reinitializes the core DB handle, which is the single
most dangerous sequence in the server, and rewriting its shape in the same step
as the move would make a regression there impossible to bisect. The db-lifecycle
question is the last step of this fold, not part of it.

The move also pulled 512 lines out of ungated src/services/ into a gated domain,
which is how the per-domain ratchet caught that the per-entry extraction loop had
no tests at all: the zip-slip refusal, the running decompressed-byte cap (the
declared size in the central directory is attacker-controlled, so only the
running total catches a lying archive), a corrupt stream not being dressed up as
a size refusal, and the reopen failure that has to report restart-required rather
than success. Six cases now cover it, and backup goes from 84% to 96% statements.
2026-08-09 18:44:59 +02:00
Maurice 6680bc7c74 Split PluginsModule into four, and gate coverage per domain
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one
class, which made it the hub most of the Nest graph ran through: AdminModule
imported it for a single cascade-disable call and inherited all of it. It is now
PluginsRuntimeModule (supervisor, capability router, hook contracts),
PluginContributionsModule (the 14 read-only hook controllers, moved to
contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a
composition root that keeps the install and delivery controllers. AdminModule
imports the runtime alone.

The three plugin files that reached for the db singleton alongside the injected
DatabaseService no longer do. plugin-host-state and signature-status take the
connection from the caller, and the three per-user settings functions that were
bare exports 'so the RPC host wiring can call them directly' are now
PluginUserSettingsService - the host wiring is providers, so it injects them.

Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number
let a well-covered domain subsidise a thin one: booking-import sits at 50% and
integrations at 20%, and the aggregate still cleared the bar, so either could
have lost another ten points unnoticed. Each domain now carries its own measured
floor, regenerated by scripts/coverage-thresholds.mjs.

Two test-side findings on the way:

expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only
throws for a string needle. Every '<X> is listed in its module providers' guard
was written that way, so all twelve of them silently stopped guarding the moment
their class moved. They go through expectRegisteredProvider now, which asserts
the array shape first.

The atlas region test paid for the one-off decompression of the multi-MB admin1
bundle inside its own 5s budget, which is why it failed under load and passed
alone. The bundle is warmed in beforeAll instead.
2026-08-09 18:44:59 +02:00
Maurice dc81a5365e Make PluginsModule bring the whole plugin surface with it
With requireTotalCoverage on, a test app that assembles a subset of AppModule
now fails to boot: the admin e2e suite pulls PluginsModule in through
AdminModule, but WeatherModule was only ever reachable from AppModule, so
weather.get had no handler and validate() refused the start.

The invariant the gate needs is that importing PluginsModule pulls in every
domain that owns part of the wire surface. WeatherModule was the one domain
missing from that list.
2026-08-09 17:38:56 +02:00
Maurice f72aba0c91 Finish the plugin RPC decorator rollout
The last 20 wire methods move onto the decorators: db.* and the three
unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface
of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the
ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the
router is down to what was never domain-specific: build the granted subset of
the dispatch map, dispatch, audit, map errors.

PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With
the handlers in the domains it has two dependencies left, so it is now
PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router
bound with this plugin as the caller, and the audit sink.

The 18 host-to-plugin hook calls get the same treatment. They used to spell out
the fn name and the timeout at each controller, which meant nothing checked that
a hook:* grant on the consent screen had a consumer behind it, that the fn
matched what the SDK documents, or that two call sites agreed on the budget.
PluginHooks declares all 18 with @PluginHook and performs them, and the
controllers call it by name.

With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without
a handler, or a hook without a consumer, now fails app boot instead of surfacing
as a PERMISSION_DENIED nobody can explain.
2026-08-09 17:38:56 +02:00
Maurice 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.
2026-08-09 16:38:43 +02:00
Maurice 294cfcd8d7 Move the addon-gated domains onto the decorators
collab, journal, atlas, vacay and collections: 32 more methods, so the registry
owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory
at 384, down from 944.

These five are the risky group, because their addon gates lived inside the deps
factory's closure rather than in the router. A handler that calls its service
directly still works, just without the check, and neither the compiler nor the
router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as
one table and asserts two things for each: with the addon off it is refused with
that addon's message, and the service is never reached. The table length is pinned,
so a later method added without a row is a failure rather than a silent gap.

One ordering detail worth knowing: on the atlas writes the router evaluated its
params before the addon gate inside the closure, so a malformed country code still
reports BAD_PARAMS rather than the addon. The handlers keep that order.

The collections service reports refusals as status-tagged errors rather than typed
ones, so mapCollectionError came across with the handlers: 403 and 404 become
RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a
HOST_ERROR instead of being dressed up as a permission problem.
2026-08-09 16:38:43 +02:00
Maurice 7bca741f28 Cover the fallback branches the moved handlers brought with them
The branch threshold over src/nest/** dipped to 79.89% after the move: the new
*.rpc.ts files carry the defensive branches the router closures had (?? '',
|| current, optional cascades), and none of them were exercised.

Adds the cases that reach them: a booking whose cascade does not fire, an update
that keeps the stored title, a bag colour of the wrong type, a non-array userIds,
a missing bag across all three bag writes, and the block delete without a partner
reservation. Also corrects two assumptions I had written into the booking tests:
title and type are required by the shared schema, so the handler's own fallbacks
sit behind it as defence in depth rather than as the first line.
2026-08-09 16:38:43 +02:00
Maurice 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.
2026-08-09 16:38:43 +02:00
Maurice 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.
2026-08-09 14:13:56 +02:00
Maurice c326d39a07 Move places, days and itinerary onto the decorators
Eight more methods, so the registry owns 38 of 113 and rpc-host.ts is under 1000
lines for the first time.

itinerary.* lives in assignments/, not in days/, because AssignmentsModule already
imports DaysModule and putting the handler the other way round would have closed a
cycle. It keeps riding on day_edit, which is what the app gates it on.

The delete path in places carries an ordering constraint that is silent when
broken, so it has two tests of its own: the journey hook has to run BEFORE the row
goes, because journey_entries.source_place_id is ON DELETE SET NULL and afterwards
there is nothing left to detach, and the place id has to be scoped to the trip
before the hook runs at all, because the hook keys on the place alone and would
otherwise detach another trip's entries even though the delete itself is refused.
2026-08-09 14:13:56 +02:00
Maurice d58f7aa399 Drop a dead expression from the files RPC test
Left over from writing the demo-mode case: a cast statement with no effect, which
eslint flags as no-unused-expressions. It is the one error in an otherwise
warnings-only run, so `eslint .` exits 1 and the Server Tests job fails on lint
before it runs a single test.
2026-08-09 13:24:58 +02:00
Maurice dbb0016cac Move the file surface onto the decorators
Six more methods, so the registry owns 30 of 113, and the deps factory is down to
660 lines from 944.

Files are the awkward corner of the plugin surface and stay that way on purpose.
Reading a file's bytes is a separate grant from reading its metadata, because a
passport scan is more sensitive than its filename, and the three writes use three
distinct rights instead of one domain action. FILES-RPC-006 pins that by watching
which permission each operation actually asks for.

This is also the only plugin path that touches disk, so the tests lean on it: the
extension blocklist, the size cap checked before the read rather than after, a
path that resolves outside the uploads root, and the demo-account upload refusal
that mirrors the REST guard.
2026-08-09 13:24:58 +02:00
Maurice ca88676858 Move packing onto the decorators and de-duplicate its privacy fan-out
Nine more methods, so the registry owns 24 of 113.

The #858 privacy logic existed three times: PackingService had the primitives, the
controller layered emitToViewers and broadcastUpdate on top privately, and the
plugin deps factory carried a standalone copy of all four, with a comment asking
for them to be kept in lockstep by hand. Migrating packing meant either copying
them a fourth time or fixing that, so the two private controller methods moved
into PackingService and the factory copy is gone. Controller and PackingRpc now
call the same code, and the controller suite proves it by passing with all 45
assertions unchanged.

The four public/private transitions get one test each, because each one is a way
to leak a private item to the whole trip room, plus a test that the privacy is
read before the write rather than after: reading it afterwards would report
"already private", skip the room delete, and leave the item on every member's
screen.
2026-08-09 13:24:58 +02:00
Maurice 62978aab5a Move five more domains onto the plugin RPC decorators
categories, weather, rates, todos and daynotes: 11 more methods, taking the
registry to 15 of 113. Handler bodies and messages are verbatim; the ledger test
tracks the split.

Two things the move surfaced rather than introduced. PluginGuards could not stay
in PluginsModule: that module imports 22 domain modules, so a domain importing it
back for the guards would close a hard cycle and the only way out would be
forwardRef. It now lives in a PluginGuardsModule leaf that imports nothing which
leads back, the same shape MailerModule got for AuthModule.

And the todo and daynote writes broadcast. Those events are not decoration, they
are what keeps an open session from showing stale data after a plugin write, so
they came across from the deps factory with the handlers, and there is a test
that a failed write broadcasts nothing.

Worth knowing for the next domains: todos are edited under 'packing_edit', not
under a todo-specific action, and TODO-RPC-003 pins that. Each new class also
asserts it is actually listed in its module providers, since a decorated class
nobody registers is invisible to discovery and answers PERMISSION_DENIED with no
other symptom.
2026-08-09 13:24:58 +02:00
Maurice 9b3d37ea29 Move the tags plugin surface onto the decorators
Wires the registry into the host and takes the first domain across, so the kit
lands with a worked example rather than as scaffolding nobody uses yet.

The registry is an additive, optional fourth constructor parameter on
PluginRpcHost, so all 107 existing construction sites keep compiling untouched;
that is what makes the rest of the rollout a per-domain move instead of a
big-bang one. bindInto runs LAST in the constructor, so a method still owned by
one of the has() blocks collides loudly instead of being silently overridden.

tags is the right pilot: four methods, a service that is already DI-native, and
it exercises the read/write permission split, the userless refusal and a
BadParams write. Handler bodies and messages are verbatim; only deps.foo(...)
became this.tags... and uid became ctx.actingUserId.

dispatch now strips the supervisor's _inv marker. The supervisor resolves the
acting user from it before dispatch and then passes the request on untouched, so
it has been visible to every handler and is in effect a reserved param name.
Nothing reads it: auditResource takes named fields only, and the one handler
that touches the params object as a whole (packing.setBagMembers) reads a named
field off it. Dropping it keeps a future strict parse from rejecting calls every
shipped plugin makes.

Two tests carry the rollout from here. rpc-coverage.test.ts derives both owner
sets from the code and proves they partition the method vocabulary exactly, so a
PR that moves a method and forgets to delete the legacy registration fails in
CI. And because a decorated class that no module registers is invisible to
discovery, with every call to it coming back PERMISSION_DENIED and no other
symptom, tags.rpc.test.ts asserts TagsRpc is actually listed in providers.
2026-08-09 11:58:56 +02:00
Maurice 6b6fbbd94c Lift the plugin RPC resource gates into an injectable
A decorated handler cannot reach the router's private tripRead / requireActor /
requireTripEdit, nor the canEditTripAs and requireAddon that live inside the
deps factory's closure. PluginGuards holds all of them so a *.rpc.ts class can
inject what it needs.

Every message is copied character for character, including the two asymmetries
that look like typos and are not: a trip read says "trip reads", while
requireActor appends the word "writes" to whatever noun it is given, and some
domains throw their own inline variant rather than calling it at all. The tests
pin the exact strings, not just the refusal, because shipped plugins read them.
2026-08-09 11:58:56 +02:00
Maurice 7b11680cc3 Add a decorator kit for the plugin RPC surface
The method-to-permission association exists twice today: declaratively in
METHOD_PERMISSION, and imperatively as 44 if (has(...)) blocks in rpc-host.ts,
with nothing asserting the two agree. protocol-paths.test.ts checks that each
method has some permission, never that it is the same one the router gates on,
and plugin-audit.ts inherits any drift silently.

@PluginMethod binds the two together. Its permission argument is typed as
MethodPermission<M>, so it resolves to the single literal the table assigns to
that method and a mismatch does not compile. validate() re-checks the same
argument against the same table at boot, because types evaporate under an as
cast. @PluginHook does the same against HOOK_PERMISSION.

UNCONDITIONAL_METHODS was missing from envelope.ts, so the three methods the
router registers outside every grant check had no vocabulary of their own.
Adding it here, with a compile-time proof that it stays disjoint from
KNOWN_METHODS: giving those three a METHOD_PERMISSION row would change which
methods isAuditable considers, and settings.get is deliberately the one
unconditional method that is not audited.

The kit is written to stay extractable into its own package, so an ESLint rule
pins what it may import. Note the rule is a deny list on purpose: these patterns
are gitignore-style, and a leading ** would exclude the parent directory of
every allowed path, which makes the ! re-includes silently ineffective.
2026-08-09 11:58:56 +02:00
Maurice 8c5a207a9b Make the AI notice say who is accountable 2026-08-09 11:41:05 +02:00
Maurice 12ff2d3228 Move the AI notice up and trim it 2026-08-09 11:41:05 +02:00
Maurice 6f76c691da Say in the README how we use AI 2026-08-09 11:41:05 +02:00
Maurice 7d0e90eb47 Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
2026-08-09 00:52:05 +02:00
Maurice 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%.
2026-08-08 23:25:57 +02:00
Maurice 86e2a96eb6 fix(client): a deliberate logout should not book a return ticket
Pressing "log out" clears isAuthenticated while the page you were on is
still mounted, so ProtectedRoute re-renders for it and sends you to
/login?redirect=<that page>. Log out from the dashboard and the next login
goes to the dashboard - beating the startup destination every time after
the first, which is exactly how it looked: worked once, never again.

A session that ended on its own still gets the return ticket, because
coming back to where you were is the whole point of that parameter. A
deliberate sign-out is a fresh start and now gets none, so the startup
destination decides. The Navbar already hinted at this with its unused
noRedirect state; the flag lives in the store instead, since all seven
call sites of logout() need it, not just that one.
2026-08-08 23:01:40 +02:00