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>
This commit is contained in:
jubnl
2026-09-01 06:45:24 +02:00
committed by GitHub
parent 54b0e4eed4
commit 394201cd32
45 changed files with 1153 additions and 118 deletions
@@ -625,6 +625,19 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
return clean;
}
/**
* Re-spawn a plugin IF it is running, so the child re-reads its instance config —
* config is handed to the child once, in the init envelope, and a second init is
* refused (same constraint that makes setOperatorEgressHosts re-spawn). An inactive
* plugin is left alone: it will read the new config at its next activation.
*/
async respawnIfActive(id: string): Promise<boolean> {
if (!this.isActive(id)) return false;
await this.supervisor.disable(id);
await this.activate(id);
return true;
}
async deactivate(id: string): Promise<void> {
await this.supervisor.disable(id);
closePluginDataDb(id);
+11 -4
View File
@@ -13,6 +13,7 @@ import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import type { PluginInstanceConfigResponse, PluginInstanceConfigUpdated } from '@trek/shared';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
// Straight from sessionManager, not the src/mcp barrel: that one evaluates
// readEnv().mcp at module scope and installs the sweep interval, which a domain
@@ -139,13 +140,19 @@ export class PluginsController {
}
@Get(':id/config')
getConfig(@Param('id') id: string) {
return { config: this.plugins.getInstanceConfig(id) };
getConfig(@Param('id') id: string): PluginInstanceConfigResponse {
return { fields: this.plugins.instanceSettingsFields(id), config: this.plugins.getInstanceConfig(id) };
}
/**
* Save the admin-owned `scope:'instance'` settings. A RUNNING plugin gets its config
* once, in the child's init envelope — so a save re-spawns it (like the egress-hosts
* PUT), and `restarted` tells the UI whether that happened.
*/
@Put(':id/config')
updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto) {
return { config: this.plugins.updateInstanceConfig(id, body || {}) };
async updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto): Promise<PluginInstanceConfigUpdated> {
const config = this.plugins.updateInstanceConfig(id, body || {});
return { config, restarted: await this.runtime.respawnIfActive(id) };
}
/**
+27 -72
View File
@@ -1,86 +1,41 @@
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import {
pluginActivateRequestSchema,
pluginEgressHostsRequestSchema,
pluginInstallRequestSchema,
pluginInstanceConfigUpdateSchema,
pluginLinkRequestSchema,
pluginRetrustRequestSchema,
pluginRouteRequestSchema,
pluginUninstallRequestSchema,
pluginUpdateRequestSchema,
pluginUserSettingsUpdateRequestSchema,
} from '@trek/shared';
/**
* Body contracts for the plugin surfaces, written to DESCRIBE rather than to
* tighten (the journey-contract doctrine, #1842).
*
* Every one of these handlers already owns its own rejection, and several of
* them answer something other than 400 for a body they dislike: activate and
* install answer 503 when plugins are disabled, and they check that BEFORE
* looking at the body. A schema that rejected first would move a 503 to a 400
* for an operator whose server has plugins off. So the fields stay optional and
* the objects stay loose: the DTO pins the wire TYPES, and the handler keeps
* every decision it already made, in the order it already made it.
* Nest DTO classes for the plugin surfaces — thin wrappers over the shared
* contracts in @trek/shared (plugins.schema.ts), which carry the schemas, the
* inferred types both sides import, and the journey-contract doctrine notes
* (#1842) explaining why each body stays loose. Nothing here may add or remove
* validation: the schema of record lives in shared.
*/
/** `id is required` stays the handler's answer, so id is optional here. */
export class PluginInstallDto extends createZodDto(
z.looseObject({
id: z.string().optional(),
version: z.string().optional(),
constraint: z.string().optional(),
withDependencies: z.boolean().optional(),
}),
) {}
export class PluginInstallDto extends createZodDto(pluginInstallRequestSchema) {}
/** `path is required` likewise, and dev-link answers 403 before reading it. */
export class PluginLinkDto extends createZodDto(
z.looseObject({ path: z.string().optional() }),
) {}
export class PluginLinkDto extends createZodDto(pluginLinkRequestSchema) {}
export class PluginActivateDto extends createZodDto(
z.looseObject({ consent: z.boolean().optional() }),
) {}
export class PluginActivateDto extends createZodDto(pluginActivateRequestSchema) {}
export class PluginUninstallDto extends createZodDto(
z.looseObject({ deleteData: z.boolean().optional() }),
) {}
export class PluginUninstallDto extends createZodDto(pluginUninstallRequestSchema) {}
export class PluginRetrustDto extends createZodDto(
z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }),
) {}
export class PluginRetrustDto extends createZodDto(pluginRetrustRequestSchema) {}
/**
* `version` pins the exact version to install — the rollback path. Omitted, the
* runtime resolves the newest TREK-compatible version itself (the classic update).
*/
export class PluginUpdateDto extends createZodDto(
z.looseObject({ version: z.string().optional() }),
) {}
export class PluginUpdateDto extends createZodDto(pluginUpdateRequestSchema) {}
/**
* `hosts` is deliberately unknown, not `string[]`.
*
* The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`,
* which means an omitted or non-array `hosts` CLEARS the operator egress list.
* That is the documented way to reset it, so a schema demanding an array would
* not tighten validation, it would remove a working admin action.
*/
export class PluginEgressHostsDto extends createZodDto(
z.looseObject({ hosts: z.unknown().optional() }),
) {}
export class PluginEgressHostsDto extends createZodDto(pluginEgressHostsRequestSchema) {}
/**
* Plugin instance config is defined by the plugin's own manifest, not by TREK,
* so there is no shape to assert here beyond "a JSON object".
*/
export class PluginConfigDto extends createZodDto(z.looseObject({})) {}
export class PluginConfigDto extends createZodDto(pluginInstanceConfigUpdateSchema) {}
/**
* `config` is unknown rather than a record: the handler answers 200 with the
* stored config for anything it cannot use, including a non-object, and a schema
* that rejected first would turn that into a 400.
*/
export class PluginUserSettingsUpdateDto extends createZodDto(
z.looseObject({ config: z.unknown().optional() }),
) {}
export class PluginUserSettingsUpdateDto extends createZodDto(pluginUserSettingsUpdateRequestSchema) {}
/**
* The route hook answers `{ route: null }` at 200 for every input it dislikes:
* a missing trip, a bad profile, unusable waypoints, a slow provider. The client
* draws straight lines on a null and shows nothing on a 400, so a schema that
* rejected a malformed body would turn a graceful fallback into a visible error.
* Loose and fully optional on purpose.
*/
export class PluginRouteDto extends createZodDto(z.looseObject({})) {}
export class PluginRouteDto extends createZodDto(pluginRouteRequestSchema) {}
+37 -9
View File
@@ -11,6 +11,7 @@ import { AddonsService } from '../addons/addons.service';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
import { hostSatisfies, hostVersion } from './install/host-compat';
import type { PluginDependency } from './install/manifest';
import type { PluginSettingsField } from '@trek/shared';
const SECRET_MASK = '••••••••';
@@ -66,6 +67,9 @@ export interface PluginListItem {
operatorEgress: boolean;
/** How many hosts an admin has actually added — so the card can nudge when it's 0. */
egressHostCount: number;
/** How many `scope:'instance'` settings fields the plugin declares — gates the admin
* settings menu item without a per-plugin fetch. */
instanceSettingsCount: number;
/** Declared dependencies (parsed) — required addons + plugin deps. */
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
@@ -113,6 +117,16 @@ export class PluginsService {
}
}
private instanceSettingsCount(id: string): number {
try {
return (
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
).n;
} catch {
return 0; // table absent (a slimmed test app)
}
}
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
const rows = this.db
.prepare(
@@ -154,6 +168,7 @@ export class PluginsService {
...rest,
operatorEgress: _oe === 1,
egressHostCount: this.egressHostCount(r.id),
instanceSettingsCount: this.instanceSettingsCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
@@ -219,24 +234,37 @@ export class PluginsService {
}
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
userSettingsFields(id: string): Array<Record<string, unknown>> {
userSettingsFields(id: string): PluginSettingsField[] {
return this.settingsFields(id, 'user');
}
/** The plugin's `scope:'instance'` settings fields, in declared order (for the ADMIN form). */
instanceSettingsFields(id: string): PluginSettingsField[] {
return this.settingsFields(id, 'instance');
}
private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] {
return this.db
.prepare(
`SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options
FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' ORDER BY sort_order, id`,
FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? ORDER BY sort_order, id`,
)
.all(id)
.all(id, scope)
.map((r) => {
const row = r as Record<string, unknown>;
return {
key: row.key,
label: row.label ?? null,
input_type: row.input_type ?? 'text',
placeholder: row.placeholder ?? null,
hint: row.hint ?? null,
key: String(row.key),
label: (row.label ?? null) as string | null,
input_type: (row.input_type ?? 'text') as string,
placeholder: (row.placeholder ?? null) as string | null,
hint: (row.hint ?? null) as string | null,
required: row.required === 1,
secret: row.secret === 1,
options: typeof row.options === 'string' && row.options ? safeArray(row.options as string) : undefined,
// Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check.
options:
typeof row.options === 'string' && row.options
? (safeArray(row.options as string) as PluginSettingsField['options'])
: undefined,
};
});
}