mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
fa0e6a3eae912fa91c0cf3c177f8ea3c55a89bd8
104 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8632f93c37 |
feat(server): correlate log lines with a request id per entry point
A 5xx stack and its access-log line were two unrelated lines, the exception filter printed the stack with console.error and no request context, and the MCP, WebSocket, plugin RPC and cron entry points logged nothing per call. A self-hosted bug report could not be traced from a failure back to the call that caused it. Every HTTP request now runs under an id held in AsyncLocalStorage: a well-formed incoming X-Request-Id is kept, anything else is replaced by a UUID, and the response carries it in X-Request-Id. The logger tags every line written inside a unit of work with `[http <id>]`. The exception filter leaves a server-side failure on res.locals and the access log writes the request line and the stack as one entry under that id; without an access log watching the response it still prints the error itself. MCP tool, resource and prompt calls (through a new `around` seam on the registry), WebSocket messages, plugin RPC dispatches and cron ticks each run through traceEntry(), which gives the call its own id (remembering the outer one as its parent) and logs one line for it: debug on success, like the HTTP access log, and warn or error with the reason on failure. The call's result and timing are unchanged, a failed cron tick is logged once rather than twice, and a WebSocket handler rejection no longer reaches rxjs's asynchronous rethrow. |
||
|
|
de8caa81a2 |
Feat/orm : mikroorm (#2416)
* base entities
* update deps
* fix date as string
* rename entities
* migrations + seeders
* feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text
* feat(db): discover entities from an explicit list instead of a glob
* test(db): createTestOrm binds an ORM to a suite's own SQLite handle
* feat(db): dialect helper is the one place SQL functions are spelled
* test(db): dialect helper refuses malformed column references and day counts
* feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips)
* fix(db): repositories mirror the statement's column set and guard their row types
* feat(db): request-context and unit-of-work helpers for the ORM, with concurrency tests
* test(server): integration suites boot on the migrated schema snapshot
* test(server): snapshot harness keeps the legacy id semantics
* fix(test): route MikroORM's dynamic imports through vitest from the setup file, not the ORM config
* test(db): coverage ratchet entries for the ORM foundation
* test(server): websocket suites boot on the migrated schema snapshot
* test(server): coverage's uncovered-file pass goes through the SWC transform
* fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer
* test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema
* chore(server): promise safety net and sweep tooling for the async migration
* refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork
* fix(server): observe every promise the async sweep left unobserved (timer, handshake, plugin-runtime test)
* test(doc-sync): unit suites await the methods the async sweep made async
* refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork
* chore(server): lint rule refusing a Promise used as a plain value, and the misses it found
* test(server): suites await the methods Task 1's forced edits made async
* refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork
* test(oauth): controller suite awaits the async service
* chore(server): db-call-graph --unawaited lists calls to async methods whose result is used without await
* refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork
* refactor(plugins): async DB-touching methods; transactions through UnitOfWork
* chore(server): db-call-graph --sync stops its transitive walk at an async callee
* chore(plugins): drop gate-only asyncs; observe the scheduler's rejection; async event-sink stub
* refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork
* refactor(auth, files): async DB-touching methods; transactions through UnitOfWork
* test(server): suites await the methods earlier sweep groups made async
* refactor(tokens): async DB-touching methods; the auth token handlers await them
* test(files): upload filters fail closed on an excluded or unresolvable type
* refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork
* refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork
* chore(server): lint rule also refuses a Promise handed to expect() without resolves/rejects
* refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork
* fix(vacay): type the loop accumulators the async sweep introduced
* refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork
* fix(server): the journey hooks are awaited by their callers, not detached
* refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork
* refactor(trips): rewrite AccommodationsService.createAccommodation .bind alias
Recipe R4 trap named in the Task 12 brief: a .bind alias of an
AssignmentsService/AccommodationsService method is typed any and hides a
missing await from tsc and the promise lint rules. createAccommodation
belongs to this task's group, so rewritten as an arrow forwarding to the
method instead of a bound reference.
* refactor(assignments): drop the unused Trip type alias
* refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork
* refactor(roadtrip, reservations): return await on transactional; document the single-flight promises
* refactor(trip-read-model, trips, feeds): async DB-touching methods; transactions through UnitOfWork
* refactor(server): remove DatabaseService.transaction — every transaction runs through UnitOfWork
* refactor(trips, feeds): drop added return annotations; no double await on the feed guard
* fix(server): phase 1 review follow-ups — awaited returns, logged upload-filter rejections, covered handshake/notice branches, call-graph gate in CI, dead test imports
* fix(server): upload filters answer multer once on a throwing callback; positive call-graph gate test
* test(db): entity metadata must match the migrated schema; fix the four drifts it found
* feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings)
* test(db): parity gate also holds declared pivot entities; per-field column types; missing-set ratchets both ways
* test(db): numeric ids for the primary-relation spike
* feat(db): re-runnable entity generator encoding the snake_case/DbTimestampType rules
* fix(db): generator guard rails — validation cannot mask hidden()/index loss; typed JSON; loud failures
* fix(db): generator keeps NOT NULL boolean defaults; fails if a JSON interface import survives
* refactor(db): every entity in the snake_case shape; document-sync entities added; one repository per entity
* fix(db): no NaN class-field initialisers; entity generator --check gate in CI; parity normaliser keeps literal case
* chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked
* feat(db): AppSettings and Users repositories; forFeature wiring pattern
* refactor(permissions): permission flags through AppSettingsRepository
* fix(server): boot-time settings read runs inside an explicit ORM request context
* refactor(audit): audit log writes through AuditLogRepository
* fix(db): PK reads refresh past the identity map; typed owned-lookup generics; e2e ORM module mirrors production config
* docs(db): owned-lookup call-site convention; refresh side effect; e2e ORM module spread notes
* fix(audit): audit rows are native inserts, not a unit-of-work flush; closed test ORMs
* fix(server): non-HTTP entrypoints run inside an ORM request context; permission loads fail closed on context errors
* refactor(categories, tags): repositories replace raw SQL
* refactor(addons): addon catalog and photo providers through repositories
* fix(db): generator captures inline UNIQUE constraints
SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_
entry before entity-generator ever sees it, so a table-level
UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX
(settings' UNIQUE(user_id, key) among ~40 others) never reached the
generated entity's uniques: metadata. Adds a general
RULE9_addImplicitUniqueConstraints (skips a column set identical to
the table's own primary key — AppSettings.key/Addons.id noise) fed by
a PRAGMA index_list/index_info scan, and regenerates every entity.
* chore(addons): well-known gate wraps only the addon read in a request context; drop unused test factories
* fix(platform): well-known gate tolerates a synchronous addon read from the context helper
* fix(db): PK lookups inside patch refresh past the identity map; corrected identity-map notes
* refactor(settings): user and instance settings through repositories
SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces
the per-user settings table's raw SQL; SettingsService drops DatabaseService
entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/
resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead
of a raw connection.
An earlier version resolved those two repositories itself from the active
MikroORM request context, so none of its six callers needed to change. That
worked in production and e2e (always inside a real request) but broke
service construction in plain unit tests across every calling domain
(addons, auth x2, maps, transit, unsplash - 185 failures on a full run),
since none of them wrap a request context. Each of those six services now
injects its own AppSettingsRepository/UsersRepository and passes them
through explicitly, matching how every other converted repository in this
migration is consumed; their own SQL is untouched (outside Plan 3a).
* test(server): harness constructors follow the Task 5 repository signatures; one test ORM per handle in the plugin host
* fix(db): generator names relation properties in captured UNIQUE constraints
* fix(settings): drop dead wiring and the speculative deleteForUser; strict row assertions
* fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed
* fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned
* feat(db): generated entities declare their repository type; test casts dropped
* fix(server): plan 3a re-review 2 follow-ups — absent-ORM boot branch pinned; airports boot hook never rejects; BOOT-SWEEP-001 asserts the seven routes; duplicate comment removed
* feat(server): pre-init photo route and WS handshake run in a request context; InviteTokensRepository
* refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed
* refactor(tokens): MCP and API tokens through McpTokensRepository
* refactor(auth): passkeys and registration invites through repositories
* fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned
* fix(server): non-numeric route ids answer the legacy not-found, never NaN (tokens, categories, tags); mint path back to two statements; identity-map regression pinned
* refactor(oauth): OAuth server tables through repositories
OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.
* fix(auth): non-numeric passkey and invite ids answer the legacy 404, never NaN; invite join type pinned
* fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped
* refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default
* refactor(auth): AuthService and account cleanup through repositories
* refactor(oidc): OIDC identities and settings through repositories
* fix(auth): invite validation keeps expires_at null on the wire; password transaction scopes and reset-token burn pinned
* fix(server): plan 3b close-out — case folding on both sides for identifier lookups (non-ASCII login regression); coverage ratchet restored; repository scaffold and instanceof ratchet on TrekRepository; D-shape tests load-bearing
* refactor(server): trip-access primitives async at every caller
* refactor(trips): trip access through TripsRepository; demo seed in a request context; dialect helpers for the trips cluster
* refactor(days): days and day notes through repositories
* refactor(assignments): day assignments through repositories
* refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories
* refactor(trip-members): membership, guests and the trip read model through repositories
* refactor(places): places, tags, ratings and place images through repositories
* fix(server): place-shadow source counts ordered in SQL; identity-map ratchets load-bearing; photo-cache race pinned
* refactor(places): import paths through repositories
* refactor(trips): trips through repositories
* fix(server): trip ids parsed once at the gate for places and assignments; NUL-safe value quoting on the SQLite platform; places parity tests completed
* refactor(trips): trip copy through repositories for the owned tables
* fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate
Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.
PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.
DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.
Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.
Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.
* test(server): plan 3c close-out — copy-source full-row parity, Kysely rollback proofs, identity-map relabels, trips e2e on the snapshot DB
* fix(server): plan 3c close-out — days create/insert answer the guard 404 for non-canonical trip ids instead of 500; exact settlement and cross-request yield ratchets
* feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets
* refactor(roadtrip): roadtrip tables through repositories
* refactor(reservations): reservations, endpoints and travelers through repositories
* refactor(feeds): ICS feed tokens and anonymous feeds through repositories
* refactor(accommodations): stays through repositories inside the existing transactions
* test(server): plan 3d — MCP test helper constructors follow the accommodations and days services
* refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables
* refactor(calendar): calendar reads and reservation visibility through one predicate
* feat(server): plan 3e groundwork — packing visibility predicate, memories access-gate audit
* fix(server): plan 3d close-out — legacy text shape for stored accommodation ids restores the date-change restamp; reservation ids parsed once at the gate; parity tests for the reservation repositories
* refactor(photos): trek photos, memories access and cache sweeps through repositories
* refactor(files): trip files and file links through repositories, with transactions added
* test(server): plan 3e — hand-built FilesService, FilesRpc and AllowedFileTypesService calls follow the repository constructors
* refactor(memories): unified, immich and synology photo sync through repositories
* refactor(collab): notes, polls, messages and links through repositories
* refactor(budget): budget items, members, payers and settlements through repositories
* refactor(todo): todo items through repositories
* refactor(packing): packing items, bags and templates through repositories
* feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins
* refactor(school-holidays): country and region catalog through repositories
* refactor(notifications): in-app notifications and channel preferences through repositories
* refactor(notifications): reminder crons, mailer, ntfy and webhook transports through repositories
* fix(server): plan 3e close-out — repository registrations restore the boot; packing assignee and bag updates parse and no-op as legacy; coverage and scoping ratchets for the budget, packing and collab repositories
* refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories
* refactor(system-notices): notice dismissals through a Nest provider and repository
* refactor(atlas): visited countries, regions and bucket list through repositories
* feat(server): plan 3g groundwork — GALLERY_CHRONOLOGICAL_ORDER Kysely ordering, unixEpochToIsoKysely helper, composite-PK DDL pins, JB5/R7 confirmations
* refactor(journey): journey and trip access control, CRUD and the trip-sync engine through repositories
* feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep
* fix(server): plan 3f close-out — ntfy operator-scope test on the live send path; vacay date-offset and shift-order parity; todo reminder window; coverage and parity ratchets for the atlas and vacay repositories
* refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories
* refactor(journey): public share links, TREK Studio books and the journal facades through repositories
* refactor(collections): access model, hydration, export/import and list CRUD through repositories
* refactor(server): journey-table survivors in trips, photos, memories, auth-cleanup and plugin-contributions through repositories
* refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories
* refactor(collections): saved places, copy-to-trip, labels and invitations through repositories
* refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories
* refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories
* test(integrations): add AirTrail saveSettings tx-asymmetry ratchet (Plan 3h Task 4 addendum)
* fix(server): plan 3g close-out — transactional book first-save, skeleton reconcile and share-link create; unknown-key entry patches are no-ops again; parity and authorization pins for the journey repositories
* feat(server): plan 3i groundwork — AuditLogRepository's reserved methods, the demo connection-swap spike, repository-fitness confirmations
* feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness
* refactor(storage): backend/category config and usage stats through AppSettingsRepository; fix the seed-file test race and the shared-replica migration-flip gap
* refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories
* feat(server): plan 3j — the withCamelCase utility, unwired, pending a contract decision
* refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories
* test(admin): pin createUser's pre-existing non-transactional uniqueness-check race (AD2-4)
* refactor(plugins): the trip-write role gate through a repository, in isolation
* refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService
* fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations
* refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories
* fix(server): plan 3i close-out — DemoModule imported where its service is consumed (e2e restored); admin addons list selects the provider relation; saveDemoBaseline awaited; backup tests on the maintenance repository
* refactor(plugins): outbound OAuth tokens and per-user plugin settings through repositories
* refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories
* refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories
* test(server): plan 4 — collapse mcp tool test harnesses onto the ORM snapshot
* test(server): plan 4 — collapse nest service test harnesses onto the ORM snapshot
* fix(server): plan 3j close-out — serialized audit-chain appends, atomic metadata and scheduler limits, rollback and race proofs for the plugin repositories
* refactor(server): plan 4 — convert the program's orphaned raw-SQL callers
* fix(server): plan 4 — un-skip PARITY-009 (FK deleteRule and index-name parity)
* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (trip-domain services)
* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces)
* fix(server): plan 4 — RULE12 paren-count scanner ignores string literals
The entity generator's RULE12_fixGarbledCheckExpressions CHECK-expression
paren-balance scanner counted every `(`/`)` character regardless of
whether it fell inside a single-quoted SQL string literal. A CHECK
expression like `status IN ('confirmed)', 'tentative')` has a literal `)`
inside its first string value; the scanner would hit depth 0 at that
in-string character and truncate the expression, losing its own real
closing paren. The scanner now tracks single-quote string state (handling
the doubled `''` SQLite escape) and skips parens while inside one.
Latent-bug fix — no shipped entity's CHECK expression has hit this shape
yet (3d's ledger: "nothing affected today").
* refactor(server): plan 4 — unify the trip-access predicate's Kysely copies
TripsRepository.findForViewer/listForUser/activeTrip each hand-wrote the
identical trip-access boolean (t.user_id = ? OR m.user_id IS NOT NULL)
through the Kysely expression builder. Task 7's security review (M1)
evaluated a shared helper and set it aside for time, falling back to the
TRIPREPO-041 cross-method parity test as a regression guard instead.
Extracted the boolean into one generic helper, tripAccessExpr
(_shared/trip-access.ts), following the reservation-visibility.ts
publicReservationExpr precedent: fully generic over <DB, TB> since the
predicate has no correlated subquery, so it composes across
TripSelectKyselyDB and ActiveTripKyselyDB without needing a narrower
fixed-alias shape. The LEFT JOIN itself stays hand-written at each call
site — tripSelectQuery's own docstring already explains why a shared join
helper can't be typed across these differently-joined outer queries.
feeds.service.ts and memories-access.service.ts no longer duplicate this
predicate at all (Task 2's facade-inline sweep already converted both onto
TripsRepository.findAccessible/getFeedTokenIfReachable/
listReachableActiveTrips, which read through the canonical MikroORM
QueryBuilder form, accessibleTripsQuery) — the remaining duplication this
task closes is the three Kysely copies inside this file.
TRIPREPO-041 (64/64 in the file) stays green as the unification's own
regression guard.
* fix(server): plan 4 — trek_photo_cache_meta.cache_key NOT NULL
The column was a bare TEXT PRIMARY KEY, which SQLite does not implicitly
make NOT NULL (unlike an INTEGER PRIMARY KEY rowid alias). Every writer
(TrekPhotoCacheMetaRepository.upsertMeta, the table's only INSERT path)
has always bound a real string — 3e's R5 confirmed this. Appends a new
migration rebuilding the table with cache_key NOT NULL (SQLite has no
ALTER COLUMN, so this follows the same rebuild recipe
Migration20200101020000 uses in the opposite direction) and updates the
entity/generated metadata to match. Schema tightening, not a behaviour
change.
* refactor(server): plan 4 — retire the safely-convertible string-form where conditions (rule 23)
Places.repository.ts's existsInTrip/findInTrip/findRaw/reclaimInputs/
listByTripAndIds and Trips.repository.ts's findPublicForShare all took a
raw `.where('col = ?', [v])` string condition even though their id/trip_id
parameters were already plain `number` (or, for findPublicForShare,
narrowed to `number` here after confirming its one caller,
share.service.ts#getSharedTripData, only ever passes a number sourced
from a Kysely-typed share_tokens row, never an unconverted URL param) —
converted all six to MikroORM's typed `.where({...})` filter object.
The remaining raw string-form conditions in src/db/repositories/** are
NOT converted here: they are the `number | string` raw-bind seam
findAccessible/isOwner's own docstrings describe (trip_id/id threaded
through unconverted from req.params), which structurally requires the
deferred guard-level single toRowId parse to land first — narrowing any
of those methods' signatures now would either break the '0x10'/'007' id
parity those docstrings warn about, or duplicate the deferred item's own
work ahead of it landing.
* refactor(server): plan 4 — trek-photos.repository.ts finishes its rename
3e Task 6 renamed the CLASS (TrekPhotosRepository -> TrekPhotoRegistrationService,
since the name collided with the generated ORM TrekPhotos.repository.ts) but
left the FILE and its test file's names unchanged. Renames both to
trek-photo-registration.service.ts / .test.ts.
The old path is kept as a thin re-export stub (the src/websocket.ts shape)
rather than repointed at every one of its ~20 importers in this pass: several
of those files sit in two sibling tasks' exclusively-owned windows on this
shared branch (Task 3's budget/packing/todo/accommodations/places ripple,
reached through tests/helpers/plugin-host.ts and mcp-test-controllers.ts;
Task 5c's held-back tests/unit/services/{trekPhotoMedia,conflictUpdate}.test.ts)
and are off limits here. Flagged for a follow-up once those tasks land: delete
the stub and repoint every remaining importer at ./trek-photo-registration.service.
* fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic
install/discovery.ts#upsert refreshed a plugin's actions and settings-field
descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no
transaction spanning either pair, let alone both: a crash between a delete
and its own reinsert left that plugin with an EMPTY actions or
settings-field set until the next discovery run, instead of its previous
still-valid rows. Wraps both pairs in one uow.transactional, so a discovery
refresh for a plugin's descriptors is now all-or-nothing.
Deliberate behaviour change, named for the user per the task brief.
DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other
production caller, registry.service.ts) picks it up the same @Optional()
way PluginRuntimeService already does, with the same guard-throw. Updates
the two hand-constructed test call sites that actually exercise discovery
(discovery.test.ts, registry.test.ts) to pass a real UnitOfWork.
* refactor(server): plan 4 — facade inline sweep part B (rosterUserIds/getPlaceWithTags)
Inline DatabaseService.rosterUserIds onto TripMembersRepository.rosterUserIds
directly in budget.service.ts, budget.mcp.ts and todo.service.ts, and
DatabaseService.getPlaceWithTags onto PlacesRepository.findWithTagsAndRatings
in accommodations.service.ts (placesRepo was already injected there).
BudgetService/BudgetMcp/TodoService collapse their constructors, dropping
the now-fully-unused DatabaseService param (budget.module.ts/todo.module.ts
register TripMembers on forFeature). PackingService keeps DatabaseService as
a vestigial param behind an optional TripMembersRepository with a fallback
in tripRosterIds: an in-flight Task 5c file
(tests/unit/services/conflictUpdate.test.ts) hand-constructs PackingService
positionally without the new repository, so its arity cannot change this
task; the fallback keeps that file's DatabaseService.rosterUserIds spy
working until Task 4 drops the param with the class itself.
* test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline
Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).
* fix(server): plan 4 — thread the discovery UnitOfWork through PluginRuntimeService too
Complements the just-landed discovery atomicity fix (DiscoveryRepos.uow):
PluginRuntimeService's own discoveryRepos getter still built the bundle
without a uow field. Threads its already-injected, @Optional() uow through
so its own onApplicationBootstrap/reload/dev-link discovery calls also get
the transactional DI5-DI8 refresh, not just PluginRegistryService's.
No throwing guard here, unlike PluginRegistryService's: several hand-built
partial-DI-graph test instances of this class (boot-registry-order.test.ts,
in a sibling task's held file window) construct it with uow omitted and
exercise onApplicationBootstrap's discovery call directly. Discovery must
never refuse outright just because a test built a partial graph, so it
degrades to the pre-existing untransacted sequence when uow is absent
(DiscoveryRepos.uow's own docstring covers this), the same "boot must
never block app init" defensiveness this file already applies elsewhere.
* fix(server): plan 4 — guard-level single parse: verify + correct the overclaiming verifyTripAccess docstring (L-2)
TripAccessGuard/TripOwnerGuard already parse :tripId exactly once
(Number(), guarded by Number.isFinite) and hand the resolved TripAccess
row to the handler via request[TRIP_REQUEST_KEY], TripOwnerGuard reusing
TripAccessGuard's own lookup when both are on the same route rather than
re-querying — verified by reading both guards directly, no change needed
there.
PlacesService.verifyTripAccess's own docstring separately claimed its
returned `tid` gets "returned... for every downstream call to reuse
(never re-parsing)" (3c's Task 9 review, L-2, carried to Plan 4). Reading
every call site (not assumed) shows this is false: `.tid` has exactly one
reader (PlacesController.requireTrip itself), which discards it — none of
requireTrip's 13 callers read `.tid` off the trip it returns; each passes
the original raw tripId string on to the write/read method it calls next,
which toRowId-parses it again itself. That second parse cannot be
eliminated by this gate alone: the same service methods are called
directly by places.mcp.ts too, an entry point with no requireTrip gate at
all (its own numeric tripId is re-stringified, String(tripId), to call
them), so each method owns its own id validation regardless of entry
point. Threading `tid` all the way through would mean giving every one of
~15 methods a second, number-only call shape for the REST path alone to
use — a real refactor, out of this docstring fix's own scope. Corrected
the docstring to say so plainly instead of repeating the false claim.
* refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub
JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a
3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/
JourneyEntryPhotosRepository were still mid-flight). Now that those
repositories are stable, relocate: JS7/JS10 (photo/asset validation joins)
onto JourneyPhotosRepository (already injected into JourneyShareService as
photosRepo, no constructor change); JS13 (public entry list) onto
JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo
read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped
against that repository's own JP_COLUMNS select list instead of a
hand-duplicated copy. JourneyShareService gains two new @InjectRepository
params (both already registered in journey-domain.module.ts's forFeature),
rippled through mcp-test-controllers.ts and journey-share.service.test.ts
(P04/P05 parity tests now pin the new homes).
* refactor(server): plan 4 — document unreachable ?? default arms after single-row aggregates
3f Task 7's fix wave flagged a cluster of `row?.x ?? default` arms after
unqualified, ungrouped aggregate reads (MAX/COUNT/COALESCE(SUM,0)) as
"same class as OauthClients:147" but left them unexamined per-site. Decided
per site: document, don't delete — TypeScript still types
executeTakeFirst()'s result as `| undefined` even though SQL guarantees
exactly one row for these unqualified aggregates, so the optional chain
can't be removed without a non-null assertion (worse, not safer). Six
sites: JourneyEntries.repository.ts (maxSortOrderForDate JG42,
countStatsPlaces JG67), JourneyEntryPhotos.repository.ts
(maxSortOrderForEntry JG91), JourneyPhotos.repository.ts (maxSortOrder
JG88/JG99), VacayEntries.repository.ts (sumFraction VC1, sumCompFraction
VC2). No behavior change — comments only.
* refactor(server): plan 4 — unify the two user-data-erasure enqueue paths
UserCleanupService.erasePluginUserData (the account-deletion transaction's
own UC2/UC3 enqueue) and PluginRuntimeService.enqueueUserErasure (the
emitUserDeleted sink's post-commit enqueue) independently walked the
identical SELECT id, permissions FROM plugins + JSON.parse + hook:user-data
filter + INSERT OR IGNORE — and both genuinely run for every account
deletion, since every emitUserDeleted call site (auth.service.ts,
admin.service.ts, trip-members.service.ts's guest removal) fires
immediately after deleteUserCompletely.
Extracted the shared filter into enqueueHookUserDataErasures
(nest/plugins/user-erasure-enqueue.ts) and pointed both call sites at it.
UC2/UC3 also come off DatabaseService onto PluginsRepository/
PluginUserErasureQueueRepository in the same change (they were the last
raw statements standing in the way of sharing one implementation) — this
narrows the Plan 3b Task 5 "stays raw, owned by Plan 3j" ruling to UC1
only (plugin_user_config/plugin_oauth_tokens/plugin_oauth_state), which
still stays raw, unaffected. UserCleanupService's orphan-data-directory
scan (a plugin uninstalled with data retained) has no equivalent on the
enqueueUserErasure side and stays there, now via the same repository.
Both enqueue calls still run per deletion (INSERT OR IGNORE keeps this
idempotent) — this unifies the IMPLEMENTATION, not the call count; not
touching the emitUserDeleted event-sink wiring itself was a deliberate,
smaller-blast-radius choice for this task.
* refactor(server): plan 4 — narrow TripMembersRepository.remove/TripsRepository.setOwner to number
Both accepted trip_id: number | string per 3c's carry note (their "stays
loose for other callers" reasoning), pending the guard-level single parse
landing first. Re-checked every caller directly: setOwner's one production
caller (trip-members.service.ts#transferOwnership) already passes trip.id,
a real number resolved earlier in the same transaction; remove's callers
are trips.rpc.ts (num(), already a validated number), trips.mcp.ts (Zod-
typed tool input, already a number) and TripMembersController.removeMember
(the one genuine raw-string caller, :id off the route with no class-level
TripAccessGuard on this controller by design).
TripMembersController.removeMember now parses :id once (toRowId, not
Number() — rule 15's NaN-into-SQL trap) and threads the parsed number to
both its own access check and removeMember, instead of passing the raw
string through twice. TripMembersService.removeMember and both repository
methods narrow to number accordingly; setOwner's WHERE also moves off its
raw-bind escape hatch onto nativeUpdate now that trip_id is typed, and
remove's trip_id half moves onto a typed .where({ trip: trip_id }) filter
(user_id keeps its own raw bind — a different, still-live NaN-safety seam,
unrelated to this narrowing).
TRIPREPO-018 and TMEMREPO-023, which pinned the retired string-bind seam
on setOwner/remove, no longer typecheck against the narrowed signatures
and are removed with an explanatory comment in their place.
* refactor(server): plan 4 — U6 gate-level id parsing for todo item ids
Rule 21's carry from 3e Task 8's review: TodoController.update/.remove
passed the raw :id route string all the way to TodoItemsRepository,
relying on SQLite's column-affinity CAST to match it instead of a typed
filter. Parse :id ONCE at the controller (toRowId, matching Task 8a's
TripAccessGuard/removeMember shape), 404 on a non-canonical id, and thread
the number down through TodoService.updateItem/deleteItem into
TodoItemsRepository.findInTrip/existsInTrip/update/deleteById, all
narrowed from number | string to number. todo.rpc.ts's num()-derived
todoId no longer needs its String() wrapper. tripId stays string | number
— a separate, already-accepted carry (
|
||
|
|
f5103d04e0 |
test: follow the qol sweep in the e2e schemas and the assertions it changed
The hand-written e2e schemas gain the new columns and place_regions, and the unit assertions take the new arguments and fields. The Public API page names GET /api/auth/app-config for the running version. |
||
|
|
8864edda18 |
feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows. |
||
|
|
2a8071f642 |
feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside search(). Its places then show up in the place search's typed-ahead list, after the core suggestions and marked with the plugin's name, on desktop and phone. An index that cannot take a request per keystroke leaves it out and keeps answering the full search only. The child reports which functions each hook object carries at load, so GET /api/plugin-search/suggest only asks the plugins that implement suggest: from the second character, three rows at most across all of them, 800 ms to answer. The feed marks plugins holding hook:search-provider and the client only asks the route while one is installed. A plugin row carries its whole place, so picking it skips the details lookup, which knows no plugin id. The phone's search results now name their source as the desktop ones do, and the desktop dropdown scrolls instead of running past the dialog. Addresses discussion #2221. |
||
|
|
1727732841 |
feat(plugins): plugins can add POI categories to the map and answer them
A new manifest capability poiCategories (up to 4, icon from an allow-list, strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the hook:poi-category-provider permission. GET /api/plugin-pois in the plugin contributions module answers them in the /api/maps/pois shape, with search_plugin_pois as the MCP twin. plugin: ids no longer count as Google place ids. SDK 1.8.0 carries the types, validation and docs. On the map, one hook merges the built-in categories with the ones active plugins declare, and the pill shows the plugin chips after a divider on desktop and phone, scrolling sideways instead of shrinking a chip below its touch target. A plugin chip searches through a repo that refuses offline and parses the answer strictly. Its pins, on Leaflet and GL, use the category's own icon and color, both checked again before they reach any markup, and the hover card lists the plugin's details as escaped text. A plugin POI can be added as a place like any other. The admin panels on desktop and phone show a chip for the new permission and the categories a plugin would add, from one shared module. Addresses discussion #1781. |
||
|
|
c7c9ba24a3 |
fix(journey): select all takes every photo in the range and imports keep their order
The photo picker's Select all took only the pages already loaded, 50 at a time, and replaced whatever had been picked on another tab. It now loads the rest of a day, trip or date-range search before selecting (cancelled by closing the picker or switching tab), adds to earlier picks, and Deselect all only clears what is in view. Add stays disabled until that loading is done. The whole-library tab keeps selecting what is loaded and shows the count as "N+". A date-filtered Immich search stopped at 1,000 photos without a word, because its page budget was absolute; it is now relative to the page asked for, with an absolute ceiling of 270 raw pages sized so the picker's deepest page still gets a full answer. Adds go out in batches of 500 ids, under the JSON body limit, and each batch is sent oldest first, so photos land in an entry in the order they were taken. When a later batch fails, the photos already saved are counted and the journey reloads while the error still shows, through one hook for desktop and phone. After a provider add, the capture-time lookup runs at most four at a time per user, shared by all of that user's batches. When it fills a row, a journey:photos:updated event reloads open journeys once per batch, from the REST routes and the MCP add tool alike. Device uploads, which arrive one file per request, get the same lookup without the event. Addresses discussion #1587. |
||
|
|
2f6f921aef |
feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens. |
||
|
|
1423ba8933 |
fix(places): follow-ups from the review of the schemeless website fix
create_place_accommodation refused the whole booking again once its website went through the place contract, for '' and free text as well, which it always took. It keeps its old input schema now and leaves a website off when the contract refuses it, so a bare host still gains https and a script link is still not stored. normalizePlaceWebsite keeps an http or https value whenever a browser opens it, the way the write contract does, so a plugin or source answering with an intranet host, an IPv6 literal or a trailing dot keeps its website. The dot check only tells a bare host from a word. A details row cached before the fix (a week for Google and Amap, until a refresh for an expanded one) hands its website out through the same helper. Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014, MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079, PLUGIN-SEARCH-2483-02. |
||
|
|
2350c3ae14 |
fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it, such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving it to a collection or a trip failed with "website must be an http or https URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and booking mails hand over websites the same way. Every place a website comes in from outside now goes through one shared helper, normalizePlaceWebsite: the index client and its nearby search, the Overpass and OSM detail mappers, Google search and details, Amap, plugin search and the booking import. It completes a bare or protocol-relative host to https, keeps http and https as they are, and drops every other scheme and anything that is no address. Only the scheme is added, so an accented path is stored as written. placeWebsiteSchema completes a bare host the same way before its check, so an older client, a cached offline result, an MCP agent or a plugin is no longer refused, while javascript:, data: and mailto: still are. The REST controller, the plugin RPC and the enrichment now store the parsed value instead of the raw one, and create_place_accommodation takes the website through the same schema as create_place. safeHttpUrl still links only a value already stored in its final form, so nothing changes on screen. Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034, SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090, PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01, PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010, AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01, BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted, FE-UTIL-SAFEURL-007. |
||
|
|
7ccc8dc03d |
fix(booking-import): say why a recognised booking could not be imported
A document the model read correctly could still come back as "No reservations could be extracted". Every type mapper can answer null when the node carries no reservationFor, or one with no name, and both call sites pushed the item only if there was one — with no else. The unknown-@type branch next to them does warn, so a node TREK did not recognise was reported while a node it recognised but could not map vanished: no item, no warning, nothing in the log. Because the file did produce a node, the "no reservations found" fallback was skipped too, and the preview arrived empty and silent. Every recognised type could disappear this way, on both the kitinerary and the AI path. Each of them now warns, naming the file, the index, the type and the reason. Three smaller silences went with it. The debug line labelled "Raw LLM Response" printed the already-parsed array, so its inspect formatting — single quotes, bare keys — read as a provider emitting broken JSON and sent this report at the JSON parser, which has handled that shape since 4.0.0. A single reservation answered on its own, with no array and no wrapper, was dropped by the list unwrapper although the schema downstream would have taken it. And a response nobody could read returned an empty list, which is the same answer as a voucher with no booking in it; it now raises, on the Anthropic client too, where a reply cut off at the token limit produced exactly this report's symptom. The plugin AI surface keeps its old empty answer, since a value is what a plugin contracts for. The retry ladder gained a rung that sends no response_format at all, for providers that support neither json_schema nor json_object. Closes #2375 |
||
|
|
2a7ad1e241 |
fix(reservations): answer an id that resolves to nothing, instead of crashing
The scoping guard asked whether a referenced id belongs to a different trip. An id that belongs to no row at all cannot, so it passed, reached the raw INSERT/UPDATE, and SQLite's own foreign-key enforcement threw. Nothing caught it, so a stale place_id from a client-side race came back as a bare 500 with no actionable message — where the same route answers a clean 400 for an id that does exist somewhere else. A second, disjoint guard now names the ids that resolve to nothing and answers 400. It deliberately does not cover accommodation_id: that column has no row to find once a shortened trip cascades its stay away, and refusing the write there would make the booking permanently uneditable. The controller guard reads the request body, which is not enough on its own — an update rebinds whatever the row already held, so a stored id whose row is gone reaches SQL untouched. day_id, end_day_id, place_id and assignment_id are all declared ON DELETE SET NULL, so a reference that resolves to nothing is precisely the state that cascade leaves behind and is now cleared to NULL, the same way a dangling accommodation_id already heals. day_accommodations' day columns are NOT NULL and have nothing to heal to, so those are refused. The plugin surface ran neither guard. It now refuses an unresolvable id, which turns a crash into an error; it still accepts an id from another trip, because taking that away belongs to the plugin contract and not to a bug fix. The reservation MCP tools already validated every referenced id against the trip. Closes #2355 |
||
|
|
a9907e2f10 |
feat(plugins): let a plugin answer place searches from its own index
TREK searches its own place index and OpenStreetMap. Neither carries a rating, so "the best rated hotel around here" has never been answerable, and an operator with a better index for their region had no way to reach the search box at all. The searchProvider hook is that way in: a plugin is handed the query, a limit, the language and the coordinate the search is biased toward, and answers with places in the same shape the core search returns, plus a rating. It sits beside the core search rather than inside it. The plugin runtime already imports PlacesModule, which imports MapsModule, so asking the providers from the maps service would close a module cycle — the same reason the other fourteen contribution controllers stand next to what they contribute to. The client asks both at once and merges, at the single point every caller already goes through, so all nine search surfaces get plugin hits without knowing the hook exists. A plugin index may never hold up the list: the client stops waiting at two and a half seconds and the host gives a provider two, which is also why the budget is the shortest of any hook here. With no such plugin installed the route answers without reaching the runtime at all. Every hit is normalized host-side and shared by the REST route and the MCP tool, so a cap that holds for the browser holds for the assistant: coordinates are range-checked, strings capped, the rating clamped to 0..5, a website must be http or https, and ids are namespaced so they can never collide with an OSM one. search_places_via_plugins is the MCP half, because what the user can see the assistant has to be able to see; search_place now points at it. |
||
|
|
bc10fba389 |
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule. |
||
|
|
b82962f95b |
Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes |
||
|
|
3f49ef8d21 |
fix(plugins): readable save button, honest respawn, scrolling hosts dialog (#2170, #2159)
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones
|
||
|
|
1e6ab069cd |
Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning
* feat(shared): default on the plugin settings-field contract
* feat(server): persist and serve plugin settings-field defaults
* feat(client): pre-fill plugin settings defaults in the admin and user forms
* feat(server): enforce required plugin settings on both save paths
* feat(client): block plugin-settings save on an empty required field
* feat(server): resolve plugin settings defaults at runtime
A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.
Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.
* feat(client): share the plugin settings form rules across all three forms
The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.
* feat(plugin-sdk): seed dev fixtures from manifest defaults
`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.
* docs(plugins): document settings defaults as runtime fallbacks
A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.
* add missing exports
|
||
|
|
394201cd32 |
feat(plugins): instance-wide settings editor in the admin UI (#2170)
* chore: bump version to 4.1.0 [skip ci] * fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone Three places answered "is this a place the trip already has?", not two: PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and kept its own hand-rolled copy of the pre-#2130 order. Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of it — savePlace calls it directly — so the coordinate fallback for a NAMED candidate was live, not latent: two distinct places sharing an address (a restaurant and the bar upstairs) would report each other as duplicates the moment their coordinates round to the same ~11 m box, regardless of name. It also never read google_place_id/google_ftid/osm_id at all, even though every collection_places row stores them, so a place renamed since it was saved could be saved again instead of being recognised by its provider id. Now walks placeMatchStrategies() from @trek/shared, same as findMatchingPlaceId: provider id, then name, then coordinates only when there is no name. savePlace passes the provider id fields through since they are already on the request body; the two bulk call sites (importablePlaces, saveFromTripPlaces) are unchanged, since their source rows don't carry provider ids to pass. Closes #2137 * fix(collections): ask the same question at all three call sites savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy already carries google_place_id/google_ftid/osm_id into the row it writes but asked without them, and the import picker never selected them at all, so a renamed place was recognised on one path and not on the other two. The picker answering differently from the import is the drift the method exists to prevent: a row shown as new would come back refused. COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker. * fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom specified", and so could the map settings tab, which left no screen from which to pick a different basemap. Leaflet answers getMaxZoom() from the map options first and otherwise from a layer that brought one, and only a GridLayer ever contributes via its beforeAdd hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd refuses an infinite one by throwing, before any clustering, so an empty trip hit it too. That path opened when the default basemap became a MapLibre style. It reaches further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless CARTO template to the app default, which is now a vector style, so every instance still carrying the old CARTO basemap fell into it on upgrade. The ceiling belongs on the map rather than on the base layer, because it has to hold whichever of the three branches renders. 19 matches what the raster and satellite layers already carried, so nothing changes for the maps that worked. SharedTripPage gets the same treatment: it has no cluster to throw, but it draws the same vector basemap and its fitBounds asks for a ceiling. mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library rather than a mock, since a mocked react-leaflet would have kept passing through all of this. * fix(map): put the CARTO key back on the template the settings preview draws The map settings fields hold what the user is editing rather than what useTileUrl already resolved, so the preview received a bare template with no key on it. resolveTileUrl then read it as a keyless CARTO url and did what it is meant to do with one: fall back to the app default. The preview drew OpenFreeMap while the fields said CARTO, whether or not a key was saved. Same reason the tab crashed for anyone on a CARTO basemap even after entering a key, which is the loop that made this hard to escape: the key is entered on the page the fallback had taken down. FE-COMP-MAP-033b pins the template the preview is handed, with and without a key. * fix(admin): put the CARTO key back on the default-settings map preview too Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. * fix(mobile): put the CARTO key back on the phone map settings preview too The third preview with the same shape, and the one that matters most while the crash is live: the phone layout is where someone would go to change the basemap when the desktop tab will not open. * fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar Closes #2136 A stay was always exported as an all-day block across its whole range, and the check-in and check-out markers came on top of it. When the stay records both ends of its clock the markers already say everything the block does, and the block is the half nobody can act on: it takes a week of calendar to repeat what two one-hour events state precisely. Same shape as the parking and rental windows in #2068. Only where the markers actually stand in for the booking, though. They are emitted once per stay and titled from its lowest-id reservation, so a second room on the same stay would otherwise be left with nothing at all. A stay that knows only one end keeps its block too, since nothing else carries the other end's date. CAL-025b covers the drop, -025c the one-sided stay, -025d the second room. * fix(mobile): the phone admin map preview needs the CARTO key too The fourth preview with the same shape, missed when the other three were fixed. Sonar pointed at it: the desktop and phone admin pages are near-identical, so the duplication report named the file the change had not reached. * chore: bump version to 4.1.1 [skip ci] * feat(plugins): admin editor for instance-wide plugin settings The scope:'instance' settings a plugin declares had API endpoints but no UI. - shared: plugins.schema.ts — the settings-field descriptor and the admin instance-config GET/PUT wire contracts, imported by both sides - server: GET /api/admin/plugins/:id/config now returns the declared fields alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child reads config once, at init) and reports it via 'restarted'; the admin list carries instanceSettingsCount so the UI gates its menu item without a fetch - client: 'Instance settings' row action opening a form modal (desktop) / MSheet (phone) in both admin shells — checkbox/select/secret rendering as in the user settings form; an untouched secret mask is never sent back - i18n: three new admin.plugins.* keys in every locale * refactor(plugins): move the admin body contracts to @trek/shared The remaining plugin DTO schemas (install, link, activate, uninstall, retrust, update, egress-hosts, user-settings update, route) now live in shared/src/plugins/plugins.schema.ts with their inferred types, alongside the instance-config contracts. The journey-contract doctrine notes (#1842) moved with them — each body stays deliberately loose so the handlers keep the rejections and fallbacks they already own, and new spec tests pin that looseness so a well-meant tightening breaks a test that explains itself. plugins.dto.ts is now thin createZodDto wrappers over the shared schemas (same class names, no controller changes), and the client's pluginInstall types its options and body against PluginInstallRequest. * fix(admin): offer Allowed hosts only to plugins that declared operatorEgress The row's egress chip was already gated on the declaration, but the kebab menu (desktop) and row action sheet (phone) offered Allowed hosts on every installed plugin — inviting the admin to widen egress for plugins that never asked for it. Both entries now render only when the plugin declares operatorEgress; the dialog's unsupported-notice fallback stays as the truth-teller for stale list data. * test(admin): close the Sonar gaps on the instance-settings form The PR gate flagged new-code coverage (76.4%) and duplication (10.9%). - The two identical 39-line handler blocks were logic, not markup — they move into one shared useInstanceSettings hook; each shell keeps its own markup (the deliberate desktop/phone divergence stays) - New tests per shell cover the untested branches: every field type renders and round-trips into the save payload (select, checkbox/MToggle, number, hint, no-label key fallback), a failed fetch is a toast, a rejected save shows the server reason and keeps the edits, and the dialog/sheet closes without saving - INS-009 covers the controller's no-body '|| {}' fallback --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Co-authored-by: Maurice <mauriceboe@icloud.com> |
||
|
|
a8a05ace40 |
fix(plugins): close the gaps between what a tool schema advertises and what it enforces
`.meta()` advertises every node of the declaration; `nodeToZod` only walks the ones it can type. Everything below follows from that gap, so the keyword allowlist moves into `sanitiseNode`, which is the one walker that sees every node. A `pattern` was compiled straight into a RegExp and run on the main thread against caller input, with no timeout and nothing to abort it: `^(a+)+$` against 33 characters stops the whole server, not just MCP. That is an escape from the supervised child into the host event loop, and it does not take malice, since a hand-written email pattern is the classic accident. Quantified groups that repeat or alternate inside are refused, and the source is capped. `mcp:tools` counted as a side effect, so `readOnlyHint` was structurally unreachable: holding that grant is what lets a plugin advertise a tool at all. Every plugin tool was therefore advertised as potentially destructive and every client's auto-approval was off. It is a host entry point like `hook:*`, and is classified as one. Property names, `default` and `examples` were the strings that never met the sanitiser. A key may carry newlines, so `\n## SYSTEM\n...` in one defeated the newline collapse everything else goes through. Names are identifiers now; sample values keep their own keys but have their strings bounded. Also refused at parse time, which is what the rest of the file already does: an object `enum` member carrying `$ref` past the deep scan, a non-boolean `additionalProperties` advertised and checked by nothing, `enum` beside `properties` skipping the children, a root `enum`/`const`/`nullable` producing a non-object schema the SDK cannot read, and a draft-4 `exclusiveMinimum: true` dropped from the check while staying in the advertisement. `required` used `in`, so a field named `toString` read as present on every call. Nodes are built with a null prototype, and the three names Zod cannot round-trip are refused rather than silently lost. A result block that had to be shortened got no marker and no log line, which reads to the model like the whole answer. It is counted now, like a dropped one, and `isError` survives every envelope path. The security test file carried two raw NUL bytes, so git, GitHub and ripgrep all treated it as binary: no diff, no blame, no review. Escaped, same runtime value. |
||
|
|
937e32924d |
fix(plugins): close six gaps in the MCP tool boundary
Found in review of the branch. Each one is a case where the host told the model or the admin something it did not enforce. Advertised schemas are now enforced. buildToolInputSchema built a loose object that checked only which keys were present, so a tool advertising an integer, an enum and additionalProperties: false accepted a string, an unlisted value and an extra property. .meta() still advertises the declaration verbatim, and the schema underneath it is now built from that same declaration, so the two cannot disagree. Anything the builder cannot enforce (oneOf, unknown formats, non-primitive enum members) is refused by parseManifest rather than advertised, and the failure lands at install time where an admin sees it. Writing the test for that turned up a live bug: z.custom() cannot be represented in JSON Schema, so building enums that way would have thrown inside tools/list for the whole session. Enums and consts are real literal types now. readOnlyHint classification fails closed. The side-effect list spelled out db:write: and missed db:create:trips entirely, letting a plugin that can create trips advertise a tool as read-only and idempotent, which is what clients gate auto-approval on. It is an allowlist of read-only grants now, and mcp-tool-annotations.test.ts walks the whole catalog so a new permission fails a test rather than producing a wrong hint. The result cap covers the whole result. It bounded each text block, so a hundred blocks cost a hundred times the limit: 6.5M characters in a probe, now 65K. Large objects are serialised with a budget instead of stringified whole and then sliced, and the block count is capped. The admin tool list was dead code. The registry preview carries only widget and tripPage, so the section added for it never rendered, and the UI hid the description behind the title when both existed. The preview carries sanitised tools now (previewManifest is exported and tested, which is how the gap survived), and both fields render, description always. plugins:use is out of the DCR default. Adding it to ALL_SCOPES meant a registration that names no scopes got it and had it pre-selected at consent. Defaults come from DEFAULT_CLIENT_SCOPES now; asking for it by name still works. The public ManifestCapabilities gained mcpTools, so a typed manifest can express what the runtime already validates. MCPANN-001..005, MCPENF-001..010, MCPPREV-001..005, MCPTOOLS-020..022, SDKP-015b/c. |
||
|
|
65a6a46d70 |
fix(plugins): invalidate MCP sessions on lifecycle and grant changes
The advertised tool surface is frozen at initialize, so anything that changes which plugin tools exist has to end the sessions built before it. tools/list_changed is not an alternative: no event store is configured, and the surface would not be re-read anyway. Unconditional, no diffing. The surface is per-session, since it depends on the caller's scopes and the plugin's grants, so there is no ctx-free set to compare against. Five routes, mirroring what admin.controller.ts already does for addons: activate, deactivate, update, retrust and uninstall. activate(id, consent), update and retrust are also the grant-change paths, so "on an mcp:tools grant change" needs nothing extra. A failed call invalidates nothing, because nothing changed. The addon cascade needed its own call. Disabling an addon can stop a plugin that publishes tools, and the addon doing the cascading is not necessarily one of MCP_RELEVANT_ADDONS, so mcpAffected does not cover it. Still gated on a plugin actually stopping, which keeps the #1414 rule that a config-only save kills nothing. Imported from sessionManager rather than the src/mcp barrel, which evaluates readEnv().mcp at module scope. MCPINV-001 to MCPINV-007. |
||
|
|
21fd04647f |
feat(plugins): advertise plugin tools on the MCP server
The advertise path, shaped like trip-warnings.mcp.ts: gate on the kill
switch, fan out over providersOf, let a failing provider contribute
nothing rather than fail the surface.
A tool is advertised only if the signed manifest declares it AND the
running build reported implementing it, the same two-sided check
callPlugin makes for exports. The manifest half is what makes the text
auditable: the child composes its loaded payload after onLoad, so without
it a sha256-pinned artifact could change what it publishes on every
restart with no re-consent.
The result envelope is built here, always. The registry returns a
handler's value verbatim and skips output validation without an
outputSchema, so a plugin returning {ok: true, data} would emit a result
with no content key and every conforming client would error. A failed or
timed-out invoke becomes isError rather than a JSON-RPC fault, so the
model can see it and try something else.
Gates in order: kill switch, then demo mode. The plugins domain had no
demo call site before, and the child has none of its own.
The service owns the process-level source rather than PluginRuntimeService
beside the other sinks, because it needs PluginHooks and PluginHooks
injects PluginRuntimeService. It clears the source on destroy, which the
existing setPluginChannelSource forgets to do: a torn-down buildApp()
would otherwise leave a live source closing over a dead runtime and bleed
into the next suite.
mcpToolCapabilities is a parallel reader rather than a capabilityList
generalisation, since that one returns string[]. It re-runs the manifest
parser instead of trusting the stored blob, which was written by whatever
parser was current at install time and whose caps are the security
control.
Two caps not in the brief: MCP_TOOLS_TOTAL_MAX across all plugins, since
8 each times twenty installed plugins would put 160 tools in front of a
model already choosing from ~200. Truncation is logged, because a
truncated surface reads exactly like a plugin that published nothing.
MCPTOOLS-001 to MCPTOOLS-019.
|
||
|
|
29de8dd9c1 |
feat(plugins): capabilities.mcpTools in the manifest
Tools are declared in the signed manifest, not only reported at load. plugin-host-entry composes its loaded payload AFTER onLoad runs, so a signed, sha256-pinned artifact could otherwise emit different tool text on every restart with no version bump and no re-consent. The host will advertise the intersection of the two. It also gives the admin something to read. What mcp:tools will put in front of every user is a manifest field now, so it can be shown on the consent screen before the grant is given. Rejects rather than drops, unlike most capability parsing: a malformed tool should fail the install visibly, not become a tool that silently never appears once the plugin is running. Caps and sanitising come from mcp-tool-schema.ts, so the install path and the advertise path cannot disagree about them. Declaring tools without mcp:tools is an error on both sides. Otherwise the consent screen lists tools that can never run. No migration: install/discovery.ts already persists capabilities as JSON. The SDK validator mirrors the checks it can make without the server's normaliser, so `trek-plugin validate` catches a bad declaration before publish rather than at install. |
||
|
|
41de91668b |
feat(plugins): normalise and sanitise plugin-authored assistant text
The security control for plugin MCP tools, landed on its own so it is
reviewable as one diff. Pure and Nest-free: no container, no DB, no
running plugin needed to test it.
The threat is not a plugin crashing. A tool name, title and description
are a persistent, admin-invisible write into every user's assistant
context, and .meta() puts the JSON Schema there verbatim. TREK's own code
proves the channel is obeyed: the static-token deprecation notice is
delivered in a tool result precisely because that is what an assistant
reliably surfaces.
sanitiseAssistantText is a sibling of stripEmoji, not a change to it.
stripEmoji preserves newlines on purpose (PDF paragraphs) and ~15 render
call sites depend on that. The new one collapses every whitespace run to
a single space, so a description cannot forge "## System" as a heading
that reads to the model like host text, and strips more than the brief
asked for: the bidi overrides and isolates and U+2028/9 are neither C0
controls nor \n, so they defeat both halves of the stated rule.
The schema is advertised rather than converted. z.looseObject({}).meta()
passes the plugin's own schema through byte for byte, keeping pattern,
oneOf and format, which a hand-rolled converter flattens to unknown. The
.check() keeps required enforced, so the handler cannot receive a call
missing arguments its own schema declared mandatory.
That only works with a normaliser in front, since .meta() is a
passthrough. It rejects a root type that is not object (which would
advertise a non-object inputSchema while the runtime still passes an
object), rejects $ref/$defs/definitions at any depth, and caps bytes,
properties and nesting. It strips a top-level id, which vanishes from the
advertisement and lands in z.globalRegistry._idmap, a strong Map unlike
the WeakMap holding the rest.
Sanitising recurses into every nested description, title and string enum
member. That is the part a top-level cap misses, and where a property
named trek_session_token with a plausible description would live.
Annotations are clamped asymmetrically. readOnlyHint gates client
auto-approval, so a plugin may lower its danger level and never raise it;
openWorldHint goes the other way, since egress makes a tool open-world
whether it admits it or not. db:own is deliberately not a side effect:
counting the plugin's private SQLite would make nearly every plugin
non-read-only and kill auto-approval for legitimate read tools.
MCPSCHEMA-001 to MCPSCHEMA-030.
|
||
|
|
21f4a1241b |
feat(plugins): mcp:tools permission and the mcpToolProvider hook
Modelled on the hook:* family rather than on geolocation:read. The host dispatches INTO the child for every tool call, so it needs what HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check, the second gate in invokeHook, and the acting-user binding. That last one is the point. The user is bound host-side from the supervisor's invocation map, so a tool called by user A reaches A's data whatever the model puts in args. The permission keeps no hook: prefix because the surface it opens is MCP, not a TREK render slot, and nothing keys off that prefix. It is the first HOOK_PERMISSION value shaped that way; `satisfies` still holds. One commit, because it cannot be split. requireTotalCoverage makes a HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a test failure, and PluginRpcRegistryService.onModuleInit runs in every buildApp() — so a half-landed version breaks every integration test in the repo. The generator is chained to it too: it refuses to run without both the en admin label and the CLI hint. 15s is the longest budget of any read hook. The others back a render someone is waiting on and fall back when they time out; this one backs a chat turn, and the plugin is likely calling a third-party API of its own. Still under the supervisor's unused 30s default, which is a hung client rather than a budget. One callTool fn for every tool, since tool names are runtime data and hookContracts() is keyed by (hook, fn). PluginDefinition exists in two copies with no generator between them, so both get McpToolProvider. grantGaps and the dev mock host need no change: both read HOOK_PERMISSION, which is generated. PLUGHOOK-010, plus the CONTRACTS row. |
||
|
|
c56521b4a4 |
feat(server): process-level plugin MCP tool source
Sibling of plugin-event-sink.ts: the plugins layer registers a source, and src/mcp/tools.ts reads it as a defaulted argument. mcp-transport/ is not touched at all, so it never learns that plugins exist and the tool-call audit row for a plugin tool comes out of code that did not change. Pull rather than push, unlike the event sink, because the answer depends on the session's ctx: there is nothing a push could carry ahead of time, and reading it per attach means activating or uninstalling a plugin takes effect on the next session with nothing to invalidate. Same argument channel-registry.ts makes for setPluginChannelSource. An unset source means "no plugin tools" rather than an error, the right answer for any app built without the plugins runtime. MCPSINK-003 pins that case, which none of the sibling sinks covers today. Not try/caught here: attach() already contains a throwing source and logs which one failed, and catching again would swallow that. The harness takes dynamicTools explicitly so suites exercise the path without installing a process-level source. MCPSINK-001 to MCPSINK-005. |
||
|
|
6c7e257c17 |
test(plugins): cover the mock double and the child proxy for addEntryPhoto
The handler had eight cases, its two doubles had none, which Sonar counted as new code without coverage. The mock is the more useful of the two to pin anyway: an importer developing against it should hit the same refusals it will hit in production, so the SVG, the missing extension, the empty payload and the size cap are all asserted against the double rather than only against the host. |
||
|
|
661777f81f |
feat(plugins): let a plugin attach a photo to a journal entry
A journal importer can create the journey, create its entries and write their stories, and then it stops: there was no way to give an entry a picture. The seven journal.* methods cover text and nothing else, so an importer reading an export archive could only upload its photos as trip files, which live on a trip and never reach the journey's gallery. journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing gallery photo and not a provider asset: an importer holding an archive has neither, it has a file it just unpacked. The access decision stays where it already was. addPhoto answers null unless the acting user may edit the journey the entry belongs to, and null becomes RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user does. Everything the REST upload refuses, this refuses too: images only and no SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The stored filename is the host's, never the plugin's, because the gallery dedupes on file_path. The object is written before any row points at it and removed again when the link is refused. JournalRpc moves into its own module for this. It now needs StorageService and the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists precisely so places, assignments and the plugin host can reach a few journey functions without dragging both photo providers into their graphs. Discussion #1365 |
||
|
|
4ba407aead |
feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every published version with its own server-computed compatibility verdict, so the client never re-derives semver range logic. A deliberate non-latest pick sets an update hold on the row, which keeps the update banner and "Update all" from immediately nagging the rollback away. The hold releases when an install lands back on the newest compatible version by any path, or when the admin resumes updates explicitly. An unresolvable registry never sets one: refusing to pin on missing information beats silently muting future updates. |
||
|
|
dc74d59b11 |
fix(plugins): stop three ways a hostile archive or child could stall the host
All three sit in code whose whole job is to survive a plugin that misbehaves. readTarGz trusted the tar size field, which parseInt reads with a leading minus. At -512 the cursor advance cancels out and below it goes backwards, so the same header is read forever while every pass appends a member — a 70-byte upload pins the main thread and exhausts the heap, synchronously, taking HTTP, the socket and the database with it. The entry budgets never get a say: they run on the list readTarGz has to finish building first. Sideload is the reachable path. The supervisor handed onMessage's promise to an EventEmitter listener, which discards it. A rejection there is an unhandledRejection, the host has no net for it, and Node ends the process — the supervisor killed by the plugin it contains. dispatch() read `'_inv' in req.params` before handle()'s try/catch, so params that are not an object threw a TypeError into exactly that discarded promise. |
||
|
|
bc59289f73 |
fix(plugins): keep a plugin id from pointing outside its own directory
`POST /api/admin/plugins/:id/uninstall` walks its route parameter into pluginCodeDir() and hands the result to a recursive remove. Express decodes `%2F` after routing, so `..%2F..%2Fuploads` matches the route and reaches the helper as `../../uploads` — path.join then lands in the uploads tree, and `deleteData` gets the data root the same way. Verified against the express version in the lockfile. pluginCodeDir and pluginDataDir now reject an id that could leave its parent. Narrower than the manifest's ID_RE on purpose: that rule wants 3-40 lowercase characters and belongs at install time, while this one only has to make the id unable to steer a path, so it stays permissive about length and case. Every other filesystem sink in the backend already has its guard — storage keys, extraction, the wiki, backup filenames. This one did not. |
||
|
|
decc0e1a12 |
fix(plugins): boot enabled plugins after the rpc-registry scan, not before
PluginRuntimeService booted enabled plugins from onModuleInit, but Nest fires
same-module onModuleInit hooks in providers-array order, where the runtime
precedes PluginRpcRegistryService — so every boot-activated plugin's rpc host
was bound from a still-empty registry and its first RPC (typically db.migrate
in onLoad) came back PERMISSION_DENIED on every cold boot, until an admin
deactivated and reactivated it by hand. Move the boot reconcile to
onApplicationBootstrap, which is guaranteed to run after every module's
onModuleInit, registry scan included.
Also fix the denial message, which interpolated the plugin id where the
permission belonged ('db.migrate requires a permission "featured-guides" was
not granted'); it now names both the missing permission and the plugin.
|
||
|
|
d110b3bb6d |
Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation * test(plugins): exercise the absent-apiVersion default * feat(plugins): generate the core-event catalog into host-facts * docs(plugins): explain the packing.listBags write-permission binding * test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header * fix(plugin-sdk): validate settings options and oauth like the host * fix(plugin-sdk): model the full manifest shape and stop dropping fields * fix(plugin-sdk): drop non-string routeProfiles icons like the host * docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId * feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host * feat(plugin-sdk): expose the generated core-event catalog * feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0 * feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins * chore(plugin-sdk): fix stale mirror docs and the colliding permissions export * docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing * feat(plugin-sdk): raise the scaffold trek floor to 4.0.0 * docs(wiki): bring the plugin pages in line with the current runtime * docs: bring the workspace CLAUDE.md guides up to date and make them committable * feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates * docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing * chore(plugin-sdk): 1.6.0 |
||
|
|
8e5fdaa877 |
fix(server): tighten the guard, audit and token paths
Permissions cached an empty map before it had loaded, so a DB error during the load cached permissive defaults and every later check answered from that cache - fail-open, and it never expired. The rate limiter grew its per-IP map forever; the GC the legacy version had was dropped in the fold. Idempotency did a check-then-insert, so two concurrent duplicates both ran the handler. Audit rows recorded whatever X-Forwarded-For said, which the client writes, and the request-body debug logger redacted by exact key match - it missed refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect from the client's Host header. CSP asked for unsafe-eval on top of the wasm-unsafe-eval it actually needs. The OAuth deny path redirected to an unvalidated URI, ws-token minting had no rate limit at all, and PKCE was skipped when code_verifier was absent rather than refused. An MCP session resumed with a token whose scopes had since been reduced kept the capability set it was created with, and the session cap was checked before the async registration that increments it. Plugin frame containment was lexical, so a symlink out of the directory was never noticed, and instance config accepted keys the manifest never declared. |
||
|
|
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 |
||
|
|
3a0604b3f5 |
feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED The switch landed last release with nothing behind it. This is what it turns off, plus the four fixes that came out of walking every one of these surfaces. Nothing here changes anything for an install that does not set the flag. That is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for any unparseable value, no other variable can turn it on, every settings key stays writable, and splitManagedKeys hands back the same object rather than a copy so no write path can quietly diverge on self-hosted installs only. WHAT THE MODE WITHHOLDS Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and reload, the Ollama model list and pull, and the SMTP test send. Each names the reason it is not the instance admin's, and the reason is mandatory. Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles and the WebAuthn pair share one handler with keys the admin does own, and the llm_* family is reachable through per-user settings, the instance defaults and the addon config alike, so a route-level refusal would be both too coarse and incomplete. A locked key is skipped and reported as managed_keys; the rest of the save goes through. Refusing the request would take the registration toggles down with the SMTP block. GET /api/auth/me/settings stops returning the three API keys in the clear. It is the only endpoint that decrypts a stored credential back to the caller, which is fine when the admin pasted it in and wrong when the operator supplied it. Two multipart routes check in the handler rather than the guard: a guard throws before the parser drains the body and the client gets an ECONNRESET instead of the 403 (PROFILE-015). They still carry the marker so the boot gate sees them. WHAT KEEPS IT FROM DRIFTING validate-managed-routes.ts refuses to boot on a marking that is not on the reviewed list, a list entry whose route lost its marking, or a marking with no reason. The third case has no equivalent in the guard gate next door and is the one that matters most: a blank reason destroys the only record of why a control was taken away, and nothing recovers that later. It found two wrong entries in my own list while I was writing this. managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to either list belongs to neither until somebody decides, and that is the failure this prevents: the lists grow, the mode does not. PLACES_API_BASE AND PLACES_API_KEY The nine Places endpoints spell out googleapis.com, so an install that wants those calls to leave through something of its own has no way to say so. One variable, substituted where every call already funnels through. A second one supplies the key, and when it is set the users table is not consulted at all — a per-user key would route around whatever the endpoint counts, and those columns are readable by any admin of the instance. FOUR FIXES THAT ARE NOT ABOUT THE MODE An admin setting somebody's password now bumps password_version and clears their MCP tokens. An admin sets a password for one reason, and until now verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the one action taken to lock them out was the one action that did not. The four OIDC provider calls go through the SSRF guard. Three are worse than they look — the token, userinfo and JWKS URLs come out of the discovery response, so they are chosen by the other end, not by an admin. safeFetchLlm is renamed safeFetchAdminConfigured and keeps its old name as an alias, because it is the variant that allows loopback and LAN: a self-hoster with Authentik on localhost:9000 must keep working. The token exchange follows no redirect at all, since one would hand client_secret to a second host. DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half has existed since passkeys landed; the TOTP half never did, which left "somebody on the trip lost their phone" with no answer short of an operator in the database. Never for the caller themselves. TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally administered. A warning is right for a machine its owner chose to trust; where the person who set the flag and the people whose data is in the database are not the same person, fail closed. TEXTS Four strings lose the phrase "self-hosted" because they describe somebody else's software — an Ollama endpoint, an AirTrail instance, a service a plugin talks to — and read better without it in both modes. The one string that describes TREK itself keeps it, and the mode picks a second key instead, so self-hosters lose nothing. A grep gate in shared/ keeps the canonical locale honest, because the parity check compares the file set and the top-level keys and never looks at a value: re-wording a string is invisible to CI today, and so is putting it back. * feat(maps): supply the map credential with the instance when centrally administered MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value. SettingsService injects it when the settings are read, and picks GL as the provider if the reader is still on Leaflet, since a token is always present. The key moves from the customer set to the locked set. It is the one locked key whose value is public by design — a pk.* token has to reach the browser to be useful — so this is not about secrecy: a per-user save landing on top of the instance token would only break the map for that user. Both settings fields for it go away with it. * fix(auth): count the operator key when reporting whether Google features work has_maps_key only looked at the users table, but getMapsKey prefers the PLACES_API_KEY env over anything stored there. On an install that supplies the key that way and has an empty column, every lookup worked while the client was told there was no key and hid the search, the autocomplete and the place photos. Found by walking the running instance rather than the code: the map came up, the search box did not. * feat(admin): hide the settings a centrally administered install cannot act on A control that answers 403 is worse than no control: the AI section rendered the refusal as "Request failed with status code 403", which tells an admin they broke something rather than that the decision is not theirs. So the surfaces go rather than being disabled in place: - AI parsing and AirTrail leave the addon list, and the AI addon route now refuses outright. The endpoint, the model and what a document costs come with the instance, so there is no instance decision left — not even whether it runs. - Immich and Synology Photos leave it too. Both connect to a server the reader runs at home, and there is no route from here to one. - The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and origins follow the same rule: they configure something the operator owns, and a wrong RP ID on a shared parent domain reaches past this instance entirely. - The About tab goes. It is about the project — what TREK is, where to file a bug, where to support it — and is aimed at the person running the software. The version and the link to the source move to the sidebar footer, on both the desktop page and the mobile twin, because AGPL 13 wants that offer wherever people reach the software over a network. - The update check stops asking GitHub and reports nothing available. Separately, the version footers lose the "self-hosted" suffix everywhere, not just here. It read as a label on the software rather than on the install, and the version alone says what it needs to. * feat(config): refuse to boot centrally administered without an at-rest key Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset, the at-rest key falls back to a file in the data volume, and backupService bundles that file into every archive it builds. The archive is downloadable by any instance admin, so the encryption ships with its own key in the same zip. The skip in backupService already handles this — it only omits the file when the key came from the env — but nothing made sure the env actually carried one. Provisioning sets it; forgetting it was silent. validateEnvAtBoot now carries a second kind of check next to the schema: cross- field rules that only apply with the flag set, for combinations where every value is individually valid and the result is wrong. Empty without the flag, so an install that never asked to be centrally administered cannot meet it. * feat(client): add an attachment point for install-specific screens Two empty exports on the client, an empty module on the server, and the four places that read them. Nothing here adds a route, a nav entry or an endpoint — the arrays are empty and the module contributes nothing, which managed-ext.test pins so it stays that way. Read unconditionally, never behind a flag. On the server a conditional import would tie the module graph to an environment variable, and the graph is built once per app while the flag is read live; on the client an empty array costs nothing to spread, so every build walks the same path instead of one build walking a path nobody tests. Anything attached here follows the same rules as the rest of src/nest and src/components: a real module with a controller and a provider, bodies through the Zod pipe, routes under the global guards, tests. * test(shared): move the wording gate out of src so shared typechecks again CI installs shared with its own dependencies only (npm ci --workspace shared), so there is no @types/node and the `node:fs` import inside src/ failed to compile. The tests passed the whole time — vitest supplies the module — which is why this only showed up in the typecheck step. The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the same way that one does. It also runs on its own now (`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names a line you have to go find. * test(server): repair the call sites typecheck:tests caught tests/ sits outside the build tsconfig, so none of this showed up in a green test run — which is exactly the gap `npm run typecheck:tests` exists to close. Three kinds, all mine: - Seven `import type` paths for RuntimeEnvService carried one `../` too many. Type-only imports are erased, so vitest never resolved them and nothing failed. - VersionCheckJob and PluginsController grew a constructor parameter and eleven hand-wired `new` calls kept passing the old count. The argument arrived as undefined and no assertion happened to read it. - ConditionContext grew `managed`, and the shared context object in the conditions suite did not. The condition also had no test of its own, so it has one now: both directions, since the registry uses `is: false` to keep a notice away and a notice that only belongs on a centrally administered install would use `is: true`. |
||
|
|
9dd0a851ca |
fix: eleven reported bugs across atlas, budget, collections, journey, vacay and the LLM settings (#1923)
* fix(mcp): accept per-leg times on create_transport and update_transport metadata was pinned to Record<string, string>, so a legs array could not get through and a multi-leg flight built over MCP lost every intermediate time. The booking ended up with correct endpoints and no legs, which every reader then treats as a single segment. legs is now a real, typed parameter with its contract in @trek/shared, shaped exactly the way the planner form and the importers write metadata.legs. The folding checks what the readers depend on: one leg fewer than endpoints (the AirTrail dedupe builds its signatures on that), day ids that belong to the trip, airport codes that agree with their endpoint, and the times, where a stopover endpoint carries the departure and the last one the arrival. metadata keeps its type and its replace semantics. Segments smuggled through metadata.legs as a JSON string used to be stored and then ignored by everything that read them; they are refused now and point at the legs parameter instead. Closes #1914 * fix(atlas): refuse a bucket list entry that is already on the list Nothing gave a bucket entry an identity, so the same country could be added as often as it was clicked. A wish is now identified by name, country, target date and, where it has them, coordinates. A different target date is a different wish and still lands, which is what the report asked for; two real places sharing a name stay separate because their coordinates differ. Empty string and NULL are normalised on both sides of the comparison, otherwise the dateless case, which is the one that was reported, would slip through. Deliberately no unique index and no migration. better-sqlite3 is synchronous and the server is a single process, so the race an index would guard against cannot happen here, and an index would have needed a destructive dedupe of existing rows first. create_bucket_list_item had no target_date parameter at all, so over MCP the allowed "same place, other date" case was unreachable. It takes one now. The plugin RPC reports the duplicate as bad params instead of letting it surface as an internal error. Closes #1898 * fix(settings): keep a free-form LLM endpoint in admin hands Any account could name the address the server calls, and the guard in front of it deliberately allows loopback and LAN so a self-hosted Ollama works. That is a reasonable trade for whoever runs the instance, because the network is theirs. It is not one for an arbitrary account, which could aim a request from inside that network at a target only it chose and read how the target answered. A free base URL, and the local provider that is one by another name, now come only from an admin controlled source: the addon config, the instance-wide defaults, or an admin's own settings. OpenAI and Anthropic stay available to everyone, because they go to a fixed address with the user's own key. The resolver is where this is enforced, since it is the single point both the booking import and the plugin host pass through, and the only one that also covers values already sitting in the database. The write path refuses as well, so nothing is stored that would never be read. The hint text promised something users no longer have, so it changed with it. Closes #1772 * fix(calendar): span a date-only booking to its end date A DATE event without DTEND is exactly one day per RFC 5545, so a multi-day booking that carries an end date still collapsed onto its first day. It gets an exclusive DTEND now, built the same way the stay branch already builds one. A booking without an end date serialises byte for byte as before. While in here: the stays query joins reservations on the accommodation, and two bookings pointed at the same stay fanned it out into two events sharing one UID. Duplicate UIDs in a feed are undefined behaviour and calendar clients pick one at random. The same "at most one booking per stay" assumption sat in the accommodations service, where it was worse than cosmetic: accommodation_id carries no foreign key, so deleting a stay took only the first booking and left the second pointing at something that no longer existed. Both the sync and the delete walk all of them now. The callers keep reading linkedReservationId; the list is additional. Refs #1869 * fix(vacay): say that the region is required, because it is The placeholder called the region optional while the add button stayed disabled until one was picked. The wording predates multiple calendars per plan, which is what introduced the gate. Only the value of the existing key changed, in all locales, so the key set stays identical. Two things the same screen got wrong were in the way of a clean answer: The button was clickable while the region list was still loading, which put the user in exactly the state the wording describes, and it is the state the report was filed from. It waits for the list now. Changing the country on an existing calendar kept the previous country's regions on screen, so a German region could be picked for the Netherlands. The list is cleared with the country, which only works in combination with the loading gate: without it, the empty list would happen to enable the button instead. Closes #1813 * fix(collections): let a saved place's address be corrected The edit form offered name, category and labels, and the address only on the way in. The cause was not the form: the update contract never declared address, so the validation pipe stripped it and the UPDATE had no column for it. Coordinates were pulled through for #1435 and the address was missed. It travels the whole chain now, contract to service to both forms. No migration, the column has always been there. The mobile add sheet had no address input at all, showing whatever the search result carried as read-only, so editing would have worked while creating still would not. It has one. The MCP tool description had drifted further than the address, never having mentioned the coordinates either. Both are in it now. Closes #1870 * fix(collections): keep the place search usable after the first save The add dialog stays open on purpose so several places can be added in a row, but after a save it gave no way back into the search. A search with no results was silent, with no empty state and no toast, so typing another name and pressing enter looked like nothing happened. The reporter described both halves and it is the silent search that matches "nothing happens" most closely. There is an empty state now, using a key that already existed in every locale and was unused. The search row is sticky inside the dialog body, so it stays reachable after the save instead of sitting above the scroll position. Also fixed while in here: refreshing the collection after each add reset the select mode, the selection and the label filter. In the very flow this dialog is built for, the user quietly lost all three. Closes #1921 * fix(vacay): make the phone year grid and the dock button do what they look like Two separate faults met on this screen. Tapping a day in the year grid did nothing unless the edit mode was already on, and the only way into it was a floating button that looks exactly like the one the dock draws in the same spot. A tap now opens that month in the editor, where the cells are big enough to hit. At 390px the grid cells are around 21px, which is too small to be mutating data with in the first place. The dock's plus was not wired for this route, so it fell through to creating a trip on the dashboard. Two different actions were stacked in one place, and the wrong one was on top whenever the screen had not painted yet, which includes the time its chunk is still loading. The dock leaves the slot to the screen now, and holds the geometry so the tab groups stay symmetric. Closes #1811 * fix(budget): settle in whole cents so balances stop drifting The report is a balance that moves on its own, with nothing added, edited or removed, on a trip where everything is in euro. Two mechanisms were behind it and both are real. The display currency is a fourth value, a per-user setting, so "everything is in euro" never covered it. Each balance was converted and rounded on its own, and the set no longer summed to zero: at 0.855 and 0.9312 it came out one cent over, at 0.94 it came out even. That cent appears and disappears with the six hour rate cache alone, which is exactly the symptom. The flows were rounded separately from the balances, so the two never had to agree. And a receipt line nobody was ticked for counted toward the total, and therefore toward the payer's credit, while landing in no share. That difference is permanent, and the client sent it that way. The settlement converts once, at the edge, and does integer arithmetic after that. Equal splits distribute the credited cents by largest remainder rather than the entered total, so credits and debits of one expense cancel exactly even in a foreign currency. Display conversion goes through the same largest-remainder allocation, so the sum survives it. With that, the 0.01 epsilon can go. It could never create a cent, but it stranded them: a one cent balance was filtered out of the flows while staying visible in the list, which is the "settle up offers nothing" half of the report. Closes #1382 * fix(journey): reach the book export and the suggestion toggle on a phone Below 1024px the hero card is dropped, and it held the only way to either action. The floating bar that replaces it was never given them. It has them now, and the hero is unmounted rather than hidden, so there is no second invisible copy of the same controls in the document. The bar's own buttons moved onto the appearance tokens while they were being touched. The phone screen never had either action at all and now carries both. The book itself was hardwired to English, cover and dates included, while the keys for it had been sitting in every locale unused. It takes the page's translator and locale, sets the document language, and prints mood and weather, which were on screen but never in the book. Closes #1848 * fix(journey): offer the connected photo providers in the phone entry editor The desktop editor grew an external photo source in #1632; the phone editor that came out of the mobile rewrite never did, so editing an entry on a phone had no way to Immich or Synology. It mirrors the desktop mechanics, including the part that is easy to miss: a save that creates the entry and then fails on the photos keeps the sheet open, and without remembering what was just created the retry creates a second entry. The sheet no longer kicks off its own addon load. The list is loaded once on boot and this only reacts to it; a second load races the other consumers and overwrites the store when it lands. Closes #1808 * fix(mobile): let the document scroll so Safari can collapse its URL bar iOS Safari only shrinks its address bar when the document scrolls, and TREK's never did: the root was locked and the mobile shell put a scroll container inside it. On the bottom tab layout that costs about a sixth of the viewport, permanently. Below 768px the document is the scroller again and the full-bleed screens measure their own height instead of leaning on the shell. That makes the body scroll locks real for the first time on a phone, and they were not ready for it. Three of them reset the overflow unconditionally, one of those on every run of its effect rather than on unmount, so a system notice could hand the page back while a sheet was still open. They all go through one reference counted lock now, which is the part that had to land first. Worth knowing for the issue: this reaches the flow screens. The trip planner, the journal, the atlas and the vacay screen keep their own full-height scrollers and still will not collapse the bar. Closes #1809 * fix(mobile): keep the screen gradient visible behind the translucent cards The gradient layer introduced with the scroll change sat at z-index -10. A negative layer paints above the canvas but below the backgrounds of ordinary blocks, and body carries an opaque one, so the gradient was hidden behind it. The mobile cards are translucent and take their depth from what is behind them, so every surface ended up compositing against a single flat colour and the whole phone UI read as one tone. The layer is positioned at z-index 0 now and the content above it at 10, which puts both over body's background. Same visual as before the scroll change, and the shell still owns no scroll container. * test(reservations): teach the accommodation delete stub about the id lists The RPC walks every linked booking since the delete stopped stopping at the first one. The stub still answered with the two singular fields, so the loop had nothing to iterate and the cascade events never fired. * fix(settings): move the free-form LLM endpoint out of user settings entirely Hiding it from non-admins closed the hole, but it left the option sitting in the admin's own personal settings, which is not what was asked for and not where it belongs: an instance has exactly one address it calls out to, and there was a second, invisible place to set it. The provider list is now the two hosted ones for everybody, the endpoint field is gone, and the resolver takes the address from the admin-controlled instance defaults for every caller including an admin. It no longer reads the users table at all. The write path refuses the keys for everyone, so nothing can be stored that would never be read. The endpoint is configured on the addon in the admin settings, which already carries provider, model, base URL and key, plus the Ollama model list. Nothing is lost, it just has one home now. Behaviour change worth a release note: an admin who set a personal Ollama endpoint has to move it to the addon config. A non-admin was already in that position after the previous commit. Refs #1772 * fix(journey): make the book preview usable on a phone The preview document carried no viewport, so a phone laid it out at its default 980px while the page is A4 landscape and about 1123px wide. It spilled out of the frame, which reads as being zoomed far in. Declaring the real width lets the browser scale the page to fit. Printing is unaffected, it goes by @page. The preview chrome had the same problem in miniature: a title and two labels on one row, sized inline, which does not fit a phone and fits translated labels even less. It moved to a stylesheet, and below 640px the card goes full-bleed, the decorative title steps aside and the two actions share the width. Refs #1848 * fix(mobile): stop the shell trapping the screens' own overlays Lifting the background layer into the positioned range meant the content wrapper had to be positioned too, and a positioned wrapper with a z-index is a stacking context. Every fixed overlay a screen renders inside it got clamped to that level: the vacay view/edit FAB sits at z-50 precisely so it beats the dock at z-40, and it disappeared behind it. The layer goes back to a negative z-index and the wrapper back to no positioning at all. What it needed in the first place was for body to stop painting over it, which is one line in the phone media block rather than a new stacking context. * fix(mobile): let the phone background override actually win A media query adds no specificity, so the themed body rule further down the file kept winning on source order and painted over the shell's background layer. The override is on html body now. The gradient was back to being invisible, which is the flat single-tone phone UI again. * fix(vacay): draw today's ring around the day instead of replacing it On the phone, today short-circuited the day-cell matrix and returned a ring on an empty cell. A vacation day or a company holiday logged for today was stored and counted in the totals, but the cell showed nothing, so it read as if the tap had not taken. Today is an annotation now, layered like the school band and the shared-calendar rings, which is how the desktop month card has always treated it. The strong ink stays only where the cell has no fill of its own, since --m-ink is light in dark mode and would disappear on a pastel. * fix(map): stop serving a stale GL style document from the service worker The Workbox rule for Mapbox matched every URL under api.mapbox.com, and the style document lives there too. Under StaleWhileRevalidate the map was built from the previous revision on every load, with the refresh landing one load too late, so a style republished in Studio with different label languages kept rendering the old ones. The network tab showed a 200 throughout, because the service worker's own answer is a real 200, which is why this looked like the app rewriting text-field at runtime. The style now has its own rule ahead of the tile rules, on NetworkFirst so it is current whenever the network is, and still cached so the map opens offline. Its own small cache too: sharing an LRU with three thousand tiles made the staleness come and go at random. OpenFreeMap styles had the same problem and are covered by the same rule. Closes #1924 |
||
|
|
3c5e406229 |
feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do Recording what a museum ticket or a guided tour cost meant leaving the place, opening the Costs tab and typing it in again — while a booking has had a Costs block in its own form all along. The place form now carries that block. Same component, same flow: with nothing linked it offers Create expense, which saves the place and then opens the Costs editor for a new expense pointing at it; once linked it shows the expense with edit and remove. budget_items gains place_id beside the reservation_id it already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop and phone both, and the mobile editor carries the link through its own save path rather than the desktop one. The expense belongs to the place, not to a day assignment, so a place sitting on five days is not five tickets. Hanging it off the assignment would have meant adding a place to three days quietly creating three expenses and removing a day quietly deleting one. Deleting a place takes its expense with it in the same transaction and announces it, on every path that deletes a place: REST, both MCP tools and the plugin RPC. Closes #1298 * test(places): teach the RPC stubs about linkedExpenseIds The delete paths read the linked expense before the place is gone, so the two hand-built PlacesService stubs threw on the new method instead of reaching the hook, the remove and the broadcast — four failures out of one missing line. |
||
|
|
079064fdcd | refactor(server): delete trips.bridge via leaf membership reads and prompt relocation | ||
|
|
08dac57577 |
refactor(server): migrate plugin jobs off node-cron
The plugin host's job scheduler now uses the cron package (the one @nestjs/schedule wraps), so node-cron and @types/node-cron leave the dependency tree entirely. Verified against both real libraries that the accepted cron grammar is identical — no plugin's declared schedule tightens — and pinned that with an unmocked compat test. |
||
|
|
ba7d55f999 |
Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.
Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:
- TagsController.create answers { error: 'Tag name is required' }, pinned
byte-for-byte in two tests, and the pipe formats as `field: message`. The
DTO leaves name optional and the handler keeps the check that owns the
message. Same shape for the booking-import confirm body, which additionally
gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
and check that BEFORE looking at the body. A schema that rejected first
would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
documented reset. Typing it as string[] would have removed a working admin
action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
it dislikes, so the client falls back to straight lines. A 400 there would
turn a graceful degradation into a visible error.
files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.
PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.
typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
|
||
|
|
7078ac7a4d |
Keep the mobile FX widget off the network after unmount
Client CI was red on this branch while dev was green, and no client file had changed. The 536 test files all passed; the job failed on three unhandled rejections, all `window is not defined` from MDashWidgets, attributed to whichever case happened to be running when they landed. The widget fetches api.frankfurter.dev directly and sets state from both handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so that request was never intercepted: it left the runner, came back after the environment was gone, and React tried to schedule an update against a torn down tree. Load dependent by construction, which is why dev happened to pass. Two halves, because either alone leaves the other broken. The widget now aborts in flight work on unmount and on refetch, and treats an abort as "no one is waiting" rather than as a failure to report. That matters outside the runner too: this is a third party call on a mobile screen a user can leave immediately. The handler set gains the third party endpoints the app calls directly rather than through /api, so no suite reaches the real host. An empty rate list is the shape the widget already handles. Also clears twenty more test files, each at its root cause. Two more were passing for the wrong reason: oidc.service.test.ts built AuthService with an AtlasService in the membership slot, undefined for userCleanup and mailer, and the mailer stub in the tokens slot, the same shift admin.service.test.ts had. And CAL-010 claims to serialise a flight byte for byte, but createReservation ignores reservation_time, so the row is written with a null time and CalendarService drops it: the recorded snapshot is truthful about the data the case builds and silent about the flight it advertises. |
||
|
|
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. |
||
|
|
f691da8f7e |
Read online users through the facade, not a lazy require
admin.service.ts reached getOnlineUserIds with require('../../websocket')
inside a try/catch — the last lazy require of the ws singleton in the tree,
and exactly what RealtimeService exists to prevent. The facade gains the
third pass-through next to broadcast and broadcastToUser, delegating at call
time like its siblings so per-file vi.mock stubs keep flowing through.
The catch stays at the admin call site rather than moving into the facade: an
admin list that shows everyone offline is a better answer than a 500, but
that judgement belongs to this caller, not to every future one.
Typechecking the tests turned up two more things behind the arity errors.
admin.service.test.ts built AuthService with an AtlasService in the
membership slot and no mailer at all, so its last four collaborators were
shifted by one. Nothing failed, because no case there reaches a path that
uses them.
Its getOidcSettings/updateOidcSettings wrappers referenced methods AdminService
no longer has. Nothing called them: OIDC settings moved to OidcService and the
two cases moved with it (ADMIN-SVC-047/048 now live in oidc.service.test.ts).
Only the wrappers stayed behind.
|
||
|
|
4780a7be90 |
Typecheck the test tree, and fix what it found
server/tsconfig.json has include: ["src"], so tests/ was never typechecked.
A service that gained a constructor parameter did not break its hand-wired
call sites at compile time — the argument arrived as undefined and threw at
runtime, if anything read it at all. The notifications sweep hit 46 such
sites and only a stash-diff against a throwaway tsconfig found them.
tsconfig.tests.json makes that permanent. It is not wired into CI yet: the
tree still reports 170 errors, and turning it on now would block every
unrelated PR.
Three of the first fixes were not type noise.
DAY-SVC-034 asserted that createAccommodation rolls back a failed
reservation insert. That method moved to AccommodationsService in the
days/accommodations split, so the case was calling undefined — which throws,
which satisfied .toThrow(). The rollback has been unchecked ever since,
while the case reported green. Repointed at the owner; the invariant holds.
host-rpc.test.ts typed its response as RpcResponse & Partial<RpcError>.
Both carry ok, one as true and one as false, so the intersection is never
and all 97 property reads in the file were errors nobody could see.
auth.controller.test.ts passes {} as the body in cases that stub the service
and assert on the mapping around it. That is correct as written — the point
of several of them is that the error path never reads the body — so anyBody()
names the intent instead of inventing fields the assertions do not read.
Three changePassword calls were genuinely missing the @Res() argument.
|
||
|
|
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. |
||
|
|
ad312cdd43 |
Give day notes a domain of their own
days/ carried two fachlichkeiten with a full file set each: days.controller, days.service, days.mcp, days.rpc, days.dto next to the five day-notes ones. The note files move out unchanged; because the new directory sits at the same depth, not one import path inside them changed. The two modules deliberately do not reference each other in either direction: dayExists is raw SQL on the days table in the note service, and DaysService reads day_notes the same way, so a module edge would buy nothing and cost a cycle. DayNotesModule has to be registered in PluginsRuntimeModule as well as AppModule - DayNotesRpc owns four of the 113 wire methods and the registry boots with requireTotalCoverage, so leaving it out would stop every test app that assembles the plugin runtime. The days e2e container assembles both modules now: the note routes still nest under the days prefix, and a container that builds only DaysModule would 404 on them while production served them fine. |
||
|
|
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. |
||
|
|
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. |