mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
3.3.0 (#1472)
* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context
- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
trek:resize, so a kit plugin's auto height report no longer collapses a full
page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
never payloads, mirroring the server-side events surface
* fix(plugins): move trip warnings out of the content area
The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.
* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit
Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.
* feat(plugins): surface registry download counts in browse
The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.
* docs(plugins): document the grown bridge surface and motion classes
* fix(plugins): harden the new bridge paths
Review pass over the bridge additions:
- keep the unstable useToast() object out of the effect deps (ref instead) —
it re-created the effect on every parent render, and with the new live
repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
plugin can't dress its dialog up as a TREK system prompt; answer/refuse
moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k
* fix(planner): let plugin warning chips grow wider before truncating
The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.
* feat(plugins): sort the plugin browser by download count
Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.
* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown
A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.
* feat(plugins): add issue url link
* feat(plugins): reservations write + cross-trip reads
- db:write:reservations -> reservations.create/update/delete, gated exactly like
the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
no impersonation) and delegating to ReservationsService so the accommodation,
budget-sync, booking-notification and reservation:* broadcasts match the web
app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
acting user can access (membership baked into listTrips, never a raw
cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki
* feat(plugins): read scopes for journal, atlas, vacay and day notes
- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
journals / visited countries+regions / vacation plan across all their trips
(user-scoped like costs.listMine, each gated on its addon being enabled),
reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
audit resource labels, tests
* feat(plugins): day notes write scope
- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
'day_edit' permission (like days) with the day verified to belong to the trip;
reuses dayNoteService and broadcasts the same dayNote:* events so open
sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests
* feat(plugins): run declared background jobs on a schedule
- plugins already declared jobs {id, schedule} but the cron was never wired. The
host now schedules them: host-entry reports each job's schedule, the supervisor
starts the jobs (node-cron) when the plugin goes active and stops them on
kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
(its trip reads stay refused; it can only use ctx.db and declared egress), so
background execution is a distinct, admin-granted capability. Invalid crons are
skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
wiki, tests
* feat(plugins): read scope for saved-place collections
- db:read:collections -> collections.listMine() / collections.get(id): the acting
user's own collections (user-scoped, gated on the Collections addon), reusing
collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests
* fix(plugins): translate new permission labels to all locales + cover the new wiring
- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
(the real side-effect wiring the mocked rpc-host tests don't exercise) so the
src/nest 80% branch-coverage gate holds
* feat(plugins): dev-link — hot-reload a local plugin against real data
Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".
- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
symlinking it into the plugins volume — the loader already forks the resolved
real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
deactivate->activate primitive (same grants, no re-consent unless the manifest
widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
linked plugin bypasses the install-time signature model and, under `npm run
dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
author's source (link-safe removal for POSIX symlinks and Windows junctions).
* docs(plugins): document the dev-link real-data hot-reload workflow
Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.
* feat(plugins): dev-link admin UI
- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales
* docs(plugins): document the dev-link admin UI
The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.
* feat(plugins): enrich core events with { entity, entityId }
Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.
* feat(plugins): packing write scope with #858 privacy-scoped broadcasts
- db:write:packing -> packing.create/update/delete, gated under the app's
'packing_edit' permission (like the REST path) with the host-bound acting user
as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
exported): create/delete fan out to the item's viewers only (owner + recipients,
or the whole room for a Common item); update runs the four public<->private
transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
(rpc-host gating + the four transitions + owner-scoped delete)
* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)
The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
(22 locales), wiki, hardening tests
* feat(plugins): render tableContributor columns/actions in the reservations view
The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.
* docs(plugins): bring the permissions wikis current with this cycle
Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.
* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)
The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.
The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.
* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)
exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.
* feat(plugins): render tableContributor contributions in the places + day views
Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.
* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show
The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).
Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.
* fix(costs): settlements honor custom per-member splits (#1458)
calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.
Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.
* feat(plugins): read-convenience + todos + packing bags + tags + roster
A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests
* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)
The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.
Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.
* feat(plugins): write scopes for atlas, vacay, journal and collections
The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
bucket-list create/delete. Every row is the acting user's own (visited_countries/
visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
resolved HOST-SIDE from the acting user's active plan — a plugin can never name
another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
deletePlace, schema-validated; the service's per-collection role checks
(assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.
* fix(admin): name the Costs add-on consistently in the catalog
The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes #1464
* feat(plugins): file attach, collab content and gated member-add
- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
separate file_upload/file_edit/file_delete rights. Content arrives as bounded
base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
against the central blocklist BEFORE anything touches disk, and link targets
must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
under collab_edit + the Collab addon, emitting the same collab:* events as the
app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
it is deliberately its own permission behind the app's member_manage right
(default: trip owner only) and never bundled with a lower-risk write; the acting
user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.
* fix(maps): honor check-in/out times for hotel bookend legs (#1465)
The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.
Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.
* feat(plugins): host-mediated notifications and LLM access
Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:
- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
to notificationService.send with a new plugin_notification event (raw title/body
carried as passthrough params), so recipient resolution, channel fan-out
(bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
admin/user-configured provider via resolveLlmConfig + the existing extraction
client under the acting user; the host holds the (encrypted) key, the plugin
never sees it. Refused when no provider is configured; 20k-char caps. Output is
DATA (complete -> {text}, extract -> {results}) and never auto-written, so
prompt-injection can't reach a write without the plugin's own gated call.
plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.
* fix(budget): offer every Frankfurter-supported currency (#1470)
The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.
* feat(plugins): tableContributor into the costs, packing and files views
Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.
- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
keyed by entityId in the budget category table (a colSpan footer row per item),
the packing category group (footer after each item row, drag untouched) and the
files list (footer after each row)
A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.
* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)
* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers
New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):
- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
fail-safe. Every field normalized server-side — coordinates range-checked
(-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
inside the trip map; plugin JS NEVER runs on the map canvas, every value is
host-vetted data. Threaded tripId through MapView; fail-safe fetch
Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.
* feat(plugins): show page plugins in the mobile bottom nav
Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.
* feat(plugins): per-user plugin settings form + ctx.settings runtime read
Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).
- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
fields as an editable form (secrets write-only), reusing the declarative field
shape — no plugin markup executes
i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.
* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)
Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.
Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.
* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)
* fix(transit): anchor arrive-by search time to the destination timezone (#1479)
* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks
Two integration primitives where the host owns the sensitive part.
Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
allowlist (the common provider signature/event headers — stripe-signature,
x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
and every session/forwarded-auth header are stripped; authenticated routes get {}.
A plugin can finally verify a provider signature without any way to leak a session.
Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
secret + refresh token never leave the host; the plugin only triggers connect and
reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.
migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.
* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)
The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.
Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.
* fix(collections): keep the Add-place button reachable after the first save
On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.
Fixes #1485
* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path
Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.
- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
gated by day_edit like the accommodations REST path, with the partner-hotel
reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables
* feat(plugins): day-detail widget slot in the day panel
Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.
* feat(plugins): let the frame CSP serve a plugin's own static assets
The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.
* fix(plugin-sdk): catch the package up to the server capability surface
The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.
* feat(plugins): grant-scoped entity snapshots on core events
An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.
The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.
* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks
Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:
- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
appended to the trip PDF export, escaped into the same HTML/print pipeline
as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
(ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
the same journey access check as the journal routes + the Journey addon
Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.
* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot
A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.
Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.
* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter
Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:
- place-details provider was the ONE hook controller with no normalization: a
plugin's href/label/value went to the client raw and unbounded. Now normalized
like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
endpoint is accepted and dropped downstream instead of BadParams (no breaking
change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
provider hooks and the costs update/delete grant are now documented correctly.
* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit
Operational-readiness fixes from the completeness audit:
- Re-activation after a failure worked again: a plugin left in 'error' state by
a load-failure or crash-auto-disable stayed in the running map, so the admin's
'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
synchronously on the host thread, so a plugin in a tight loop could freeze the
whole instance (and the reap sweep). A token bucket (generous burst) + an
in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
permission jail is disabled.
* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates
The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:
- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
db:read:files:content grant (reading a passport scan is more sensitive than its
filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather
Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.
* feat(plugins): deeper integration + user-facing activity transparency
Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:
- db:meta now attaches to reservations + accommodations too (not just
trip/place/day), gated by reservation_edit / day_edit respectively — the
natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
the open reservation via reservationId in trek:context (the place-detail /
day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
every host-mediated action a plugin took bound to the signed-in user, across all
plugins, newest first — the user-facing half of the hash-chained audit. This is
what legitimizes the deliberately broad read grants: not just the admin, the
person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
fields the host ignores.
SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.
* fix(memories): load Immich album photos on Immich v3
Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).
Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.
Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.
`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.
The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.
Fixes #1492
* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery
Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.
ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.
Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.
* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export
New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.
When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.
Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.
* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db
Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.
* fix(memories): filter hidden Immich assets at the source, not just the picker
#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.
Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.
The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().
Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.
Does not address tiles already persisted before this — those still render
broken and need a separate fix.
Refs #1474
* docs(memories): correct Immich version boundaries in the hidden-asset comments
Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.
- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
`.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
servers returned hidden assets too. #1474 was therefore not purely a v3
regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
rejecting it: Immich validates with `whitelist: true` and no
`forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
an albumIds search against an older server would silently drop the album
filter and return the entire library as the album's contents. Feature
detection on `assets` (present through 1.144.1, absent on v3) makes that
unreachable; a version probe with a wrong boundary would not.
Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".
Comments only — no behavior change.
* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests
Two additions that round out the plugin platform's breadth and its authoring DX.
tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.
createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.
* feat(plugins): include plugin data + code in backups, applied on restart
A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.
createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.
* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes
Fixes from an adversarial audit of the plugin system, host/runtime side:
Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
left cached, so a re-enabled plugin's db:own threw on every call — recreate
when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
terminal 'error'; an uncaught throw AFTER activation is treated as a crash
(restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
and the buffered-event queue is cleared on the timeout path, so a hung onLoad
after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
backlog for inactive plugins can't starve deliverable work.
Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
guard (a host-local pivot to docker.sock / DB sockets), under the same policy
as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
instead of falling back to the default.
GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
tokens/state) and enqueues the own-db erasure from the CORE path, so it works
even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
export flags installed-but-inactive plugins as pending instead of omitting them.
Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
unbounded gap where a later unrelated restart would revert diverged data);
the swap is content-level (safe on a volume-mounted root) and preserves
dev-links; the decompressed-size cap is operator-raisable.
* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity
- Map-marker and atlas-layer tones were validated on String(tone) but emitted
raw, so a non-string tone (an object with a matching toString) slipped through
and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
plugin's columns no longer vanish from every table row past the first 20; the
dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
acting user), throws on a wrong user-scope notify target instead of coercing,
enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
passing author test can't hide a production RESOURCE_FORBIDDEN.
* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally
The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.
Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.
* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers
Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:
- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
http/https-only (they become <img src>), per-source count capped, failing source
skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
skipped, sensible default window.
Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".
* feat(plugins): close the create-heavy API asymmetries importers/sync hit
Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):
- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
(db:write:members + member_manage). Never removes the owner (that would orphan the
trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
— an importer can now bootstrap the journal it fills with entries and clean it up
(db:write:journal), instead of only appending to journals a human created first.
Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)
* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI
Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.
A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.
* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass
A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.
- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
shutdownAll killed children without first marking them stopped, so each child 'exit'
took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
throw escaped an EventEmitter listener as an uncaughtException and killed the whole
process mid-restore. shutdownAll now marks every entry stopped and drops it from
`running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
permanently deleted a plugin's only data copy (staging was already emptied, so a retry
couldn't restore it). It now COPIES each staged entry over the live one and only deletes
staging at the very end — `staged` stays the complete source of truth, making the whole
operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
fresh scaffold refused every user-bound capability. Restored.
* fix(plugins): final-audit medium/low findings
- GDPR export flags an active plugin whose export errored/timed out as `pending`
instead of silently omitting it (collectUserExport now returns a discriminated
result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
data (an orphan data dir) — a same-id reinstall now honours the deletion instead
of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
the activation timer), so a disable + re-enable during the backoff window can no
longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
(access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
actions).
* fix(plugins): clear no-misleading-character-class in the emoji stripper
The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.
* fix(security): trip-scope note-file deletion and guard the LLM base URL
Two reported issues:
- deleteNoteFile only matched on the note id and file id, so a member of trip A
could delete a file attached to a note in trip B by guessing its id. Thread the
trip id through the service and controller and scope the delete to it, the way
every other collab operation already does.
- The LLM extraction clients fetched the user-configured base URL directly, so a
user could point it at the cloud-metadata endpoint (169.254.169.254) and read
the echoed error body. Route both clients through a new safeFetchLlm() that
blocks the link-local/metadata range while still allowing a local or LAN Ollama
(loopback and private ranges stay reachable), pinned to the resolved IP so a
hostname can't rebind to the metadata address after the check.
* fix(security): route every LLM client through the SSRF guard
The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, which fetched the same user/admin-configured URL raw. Route
them through safeFetchLlm too, and harden its link-local check: cover the whole
fe80::/10 range (not just the fe80: prefix), the IPv4-mapped/-compatible metadata
spellings, and the AWS IMDSv6 endpoint, while still allowing a loopback or LAN
model server.
* fix(plugins): narrow emoji stripping to real emoji + close two gaps
The stripper used \p{Extended_Pictographic}, which also swallowed legitimate text
symbols a plugin renders — (c), (r), tm, rating stars (a full row collapsed to the
empty ones). Narrow it to \p{Emoji_Presentation} (+ skin-tone modifiers, regional
indicators, and the ZWJ / variation-selector / keycap / tag glue) so only colour
emoji go and text symbols stay. The whitespace tidy-up now runs only when
something was actually removed and never collapses newlines, so plain text and
PDF paragraphs keep their exact spacing.
Also strip the trip-warning message — the one render boundary that slipped
through — and skip stray files (not just directories) when enqueuing orphan
plugin-data erasures.
* fix(plugins): harden the supervisor lifecycle + restore staging
- Restore staging is now atomic: the plugin trees are copied into a `.tmp` sibling
and only a fully-copied tree is renamed to `.restore`. A copy that died partway
(disk full, crash) previously left a PARTIAL `.restore`, and the next boot's swap
deletes every live plugin dir missing from it — data loss, after the restore had
reported success.
- A late `loaded` / `load-error` from a child that finished during the kill grace no
longer resurrects a stopped/removed plugin or re-registers routes on an active one
(which leaked node-cron tasks and double-fired jobs after a re-enable).
- The crash-backoff branch clears the stale activation deadline so it can't fire
during the wait and cancel the scheduled retry.
- shutdownAll rejects in-flight activations (no more hung enable requests) and clears
the buffered-event queue (no replaying pre-restore events); activate() self-handles
the rejection so a caller that doesn't await can't crash the process.
- Erasure drains coalesce onto the one in flight instead of running concurrently and
delivering an erasure to a plugin twice.
* fix(plugins): consistent plugin-db backups + close two restore/handle races
- Backups archive a VACUUM INTO snapshot of each plugin db instead of the live file.
The archiver reads lazily while streaming, so a plugin writing during the backup
could land a torn .db plus an out-of-sync -wal in the zip — the plugin's only data
copy, silently corrupt. The snapshot is point-in-time consistent and drops the
sidecars.
- The host data handle is resolved lazily per call rather than captured once, so a
disable+re-enable within the kill grace can't have the old host's dispose() close
the db out from under the freshly re-activated one.
- A restore quiesces the running plugins regardless of whether the archive carried
plugin trees. An older archive (no trees) swaps in a different `plugins` table while
the live children keep running with their pre-restore identity — ghosts, invisible
in the restored UI and unstoppable short of a restart.
* fix(plugin-sdk): bring the mock host and dev runner to parity with the real host
A plugin developed against the local mock/dev runner must behave the same as in
production, or authors debug phantoms. Sixteen behaviour drifts closed:
- Userless handlers (jobs, scheduled tasks, events, GDPR erasure/export) run with a
userless ctx like the real host, so membership-checked reads correctly refuse.
- member add/remove: member_manage gate, owner-guard, target-exists check.
- journal: addon gate + entry_date/title validation + access checks.
- notify: emoji stripping, title/body caps, in-app link check.
- scheduler dueAt must be finite and within a year.
- db.tx: TX_CONTROL / FORBIDDEN / op-cap in both the mock and the dev runner.
- ws/users: acting-user, membership, self-only and canSeeUser gates + field whitelist.
- dev db SQL guards + row cap; the dev request always carries headers; dev user is
no longer implicitly an admin.
- core-write and small field validations mirrored from the shared schemas.
- addon toggles + a generic per-trip permission record; declaredEmits warning.
- /__dev/fire cross-site guard extended to cover the /api routes.
* fix(oauth): don't revoke active MCP sessions on a normal token refresh
refreshTokens() unconditionally revoked all MCP sessions for the
(user, client) pair on every successful refresh-token grant, not only
on replay/theft detection. Since MCP access tokens expire hourly, any
long-lived MCP client (Claude Code, Claude Desktop, etc.) silently
refreshing its token in the background would have its live session
killed roughly every hour, causing /mcp to return 404 "Session not
found" until the client was manually reconnected.
Session revocation now only happens on the replay-detection branch,
which is the actual security-relevant case; mcpHandler already
re-validates session.userId/clientId against the current token on
every request, so a legitimate rotation doesn't need to pre-emptively
kill the session.
Fixes #1475
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(atlas): match region names regardless of accent/dash formatting
The region layer's name-matching fallback (used when the geocoder's cached
region_code doesn't match the bundled admin-1 code) compared raw strings, so
a cached region_name with different accenting or dash style than the bundle
(e.g. "Ile-de-France" vs "Île-de-France") silently failed to highlight.
Added normalizeRegionName (diacritic-insensitive, dash-insensitive) and used
it everywhere region names are compared.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(atlas): connecting-flight layovers no longer mark the layover country visited
getStats' #1366 transport-only-country merge read every reservation_endpoints
row for a trip, including role='stop' (a connecting-flight layover). A plane
change through a country's airport now no longer marks it visited — only
real from/to legs do.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(i18n): improve Simplified Chinese translations
* fix(i18n): correct Vietnamese locale translations (#1502)
* fix(pwa): self-host the leaflet stylesheet instead of loading it from unpkg (#1497)
The service worker's cdn-libs rule cached the unpkg leaflet.css as an
opaque response, which the browser then rejected for the CORS <link>
request — Atlas and trip maps stayed blank until users manually
unregistered the service worker. Bundling the stylesheet from the
leaflet package removes the cross-origin fetch (and the runtime CDN
dependency) entirely, so it is precached with the rest of the app
shell. The now-dead unpkg runtime-caching rule is dropped.
* fix(packing): select quantity on focus so typed number replaces it (#1513)
* fix(planner): sort transport and booking cards chronologically (#1507)
* fix(planner): keep leg distance visible across a car rental's middle days (#1504)
* fix(costs): restore the expenses CSV export lost in the Costs rework (#1500)
* fix(client): disable request timeout for cover image uploads (#1495)
* fix(planner): scope transit quick picks to the selected day (#1460)
* fix(atlas): restore the missing Spain bounding box so southern-Spain points stop resolving to Algeria (#1490)
* fix(mobile): dvh shell, safe-area padding in Plan/Places drawer, packing name overflow (#1505)
* fix(planner): persist typed booking addresses and add autocomplete (#1496)
* fix(security): enforce trip read scope and share flags server-side
get_trip_summary returned the trip's members (with emails), full itinerary
and accommodations to any valid MCP token regardless of its OAuth scopes.
Gate the core bucket on trips:read, keeping id + title so the tool still
works for navigation (GHSA-qvw8-w937-vcmq).
The public trip share ignored share_map: days, assignments, notes and the
place list (with coordinates) were always returned. Gate them on share_map
server-side, mirroring the journey share. Same class nearby: the journey
photo/asset proxies now honour share_gallery and the shared place-photo
proxy honours share_map.
* test(dashboard): pin the clock so trip-window fixtures don't rot
DashboardPage's tests seed a trip dated 2026-07-01..07-10 and assume it is
the current/spotlight trip. Once the wall clock passes that window the
past/upcoming split relocates the trip and 14 tests fail with "Paris
Adventure" no longer rendered. Freeze "now" to a date inside the window
(shouldAdvanceTime keeps userEvent/waitFor working) so the suite is
deterministic regardless of when it runs.
* fix(atlas): derive country boxes from admin0 and make country removal stick (#1490, #1486)
Removing a visited country in Atlas was a no-op, and 43 countries could not be
geocoded at all. Two independent defects behind one report.
The bounding boxes were a hand-maintained table that had drifted from the admin0
polygons we already ship: NG, BY, GL, KP, TD, SS and 37 others had no box, so
their coordinates fell into a *neighbour's* box as the sole candidate — Lagos
resolved to Benin, Minsk to Russia. getCountryFromCoords also short-circuited a
lone bbox candidate without checking the polygon, which is how a point in
southern Spain became Algeria. Derive the boxes from admin0 instead, one box per
geometry part so countries straddling the antimeridian (RU, US, FJ, KI) don't get
a globe-spanning box, and always validate against the polygon.
Removal never persisted because Atlas re-derives countries from places and
transport endpoints on every request: those have no visited_countries row, so the
DELETE hit nothing, the client hid the country optimistically, and the next
getStats brought it straight back. Tombstone the removal in a new
hidden_countries table and suppress it in the derived passes. Re-marking lifts
the tombstone; adding a real place there brings the country back on its own.
The same #1366 endpoint merge had been copy-pasted into authService.getTravelStats
without the role filter Atlas got, so the dashboard passport card still counted a
plane-change country as visited. Filter it there too, and honour the tombstones so
the card and the map agree.
CONTINENT_MAP had the mirror-image gap (ES missing, NG present) and bucketed the
newly-resolvable countries into 'Other'; fill it out to cover all 198.
* fix(ics): compute all-day DTEND without a local-timezone round-trip (#1453)
exportICS built all-day DTENDs as `new Date(date + 'T00:00:00')` → setDate(+1) →
toISOString(). The string carries no Z, so it parses as *server-local* midnight and
the toISOString() round-trip lands a day early on any positive UTC offset. DTEND is
exclusive, so every trip exported from a server east of Greenwich silently lost its
last day — and every per-day summary event lost its only day.
Reuse dayService.addDays(), which stays in Date.UTC throughout, for both blocks.
This shipped green because the all-day test asserted DTSTART and SUMMARY but never
DTEND, and TZ is pinned nowhere in the server test setup — so CI inherited UTC, one
of the few offsets where the bug is invisible. The new test pins TZ across
Europe/Berlin, Asia/Tokyo, Pacific/Kiritimati, America/New_York and UTC; it fails
against the old code in exactly the three positive-offset zones.
* fix(client): route every multipart upload through a timeout-free helper (#1495)
The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request
deadline rather than an idle one — so any upload whose body takes more than 8s to push
is aborted mid-stream, which the server surfaces as a multer "Request aborted".
#1495 fixed this by hand-writing `timeout: 0` on the three cover uploads. That left the
identical bug live on 7 of the 15 multipart call sites, including the two that accept
500 MB: the documents addon (filesApi.upload) and backup restore. Avatar uploads — a
phone photo on a slow uplink, exactly the reported scenario — were also still on 8s.
Add postMultipart() and route all 15 sites through it, so opting out of the timeout is
the default instead of something each new endpoint has to remember. It also collapses
the three different hand-rolled Content-Type spellings; axios unsets the header for
FormData in the browser anyway so the platform can generate the boundary.
coverUpload.test.ts is superseded by uploadTimeout.test.ts, which pins all 13 endpoints
rather than just the three covers.
* fix(client): shared NumericInput so a typed digit replaces instead of appending (#1513)
Tapping a pre-populated numeric field parks the caret at the end, so the first digit
typed is appended rather than replacing: a packing quantity of 1 plus "6" committed 16.
#1513 fixed exactly one field — the packing quantity input. The weight field in the
very same row still appended, as did nine others, including Vacay's entitlement-days
which autoFocuses straight into the bug.
Extract the behaviour into components/shared/NumericInput and adopt it at all eleven
sites. It owns only the part that was uniformly broken — sanitizing and select-on-focus;
styling and commit semantics stay with each caller, since some save on every keystroke
and others on blur.
Two details worth keeping:
- The select() runs synchronously *and* on the next frame, because WebKit undoes a
synchronous select() with its own post-focus caret placement. The deferred pass is
guarded: if a keystroke lands inside that one-frame window, re-selecting would swallow
it — "48.853" typed into a coordinate field came out as ".853" — so the first input
cancels the pending select.
- The fields move from type=number to type=text + inputMode. Same keypad, minus the
scroll-wheel-silently-changes-the-value and spinner-arrow footguns.
* fix(i18n): name Costs consistently across the app and in every locale (#1464)
The Budget addon was renamed to Costs, but the rename only ever landed on the trip tab
and — in English — the admin catalog. Two problems fell out of that.
The zh locale was self-contradicting: the admin add-on card read the English literal
'Costs' while the tab it configures read '费用'. That is the bug as reported.
Underneath it, the rename had been done in `en` and copy-pasted verbatim into the other
21 locales, so 16 of them showed an untranslated English "Costs" for the trip tab.
Meanwhile the surfaces the rename missed — the public shared-trip tab, its totals label,
the login feature list ("Budget Tracking") and the member-permission checkbox — were
still correctly translated as *Budget*. Every locale therefore called the same thing two
different things.
Translate "Costs" properly in all 22 locales and apply it to all six keys, so each
locale is internally consistent and none is left holding an English literal.
Note that i18n parity cannot catch this class of drift: it compares key and file sets,
never values, which is exactly why the zh mismatch shipped green.
* fix(i18n): call packing and todo categories "lists" (#1505)
#1505 asked for the "Add category" button to read "Add list". Renaming only the button
would have left it sitting next to "Delete Category" and "Change Category", and clicking
it would still open a field placeheld "Category name (e.g. Clothing)" — so the whole
user-facing vocabulary moves together, in all 22 locales.
Covers the button, the name placeholder and its inline variant, the delete and move menu
items, the delete-confirm dialog, and the todo sidebar heading, detail label and
no-category states.
The key names are unchanged, so i18n parity is unaffected — only values move.
The bulk-import hint is deliberately left alone: it documents the CSV column layout, and
the column really is `category` in the schema. Its strings have also drifted between
locales (several describe an entirely different format), which is worth a separate pass.
* fix(plugins): audit hardening — OAuth SSRF, demo upload guard, SDK perms, ICS tz
Follow-ups from the 3.2.2 audit, all additive and non-breaking:
- The host-side OAuth token exchange now runs through the SSRF guard
(safeFetchLlm): a token_url that resolves to the cloud-metadata /
link-local range is refused and the connection is pinned to the resolved
IP, so a DNS name or IPv6 literal pointing at metadata can't be reached
and can't rebind. Loopback and LAN stay reachable so a self-hosted
internal IdP keeps working.
- Plugin files.create honours the demo-mode upload block the REST path
enforces, so a demo user can't write files to a public demo instance
through db:write:files.
- The SDK manifest validator learns hook:trip-card-provider and
hook:user-data, which the host already accepts — it was hard-rejecting
valid manifests that use them.
- ICS export validates a stored/plugin-provided timezone before it reaches
Intl, so an invalid zone degrades to a floating local time instead of
throwing a RangeError and crashing the calendar export / all-trips feed.
* fix(plugins): audit follow-ups — WAL backup, async read, dev-link gate, webhook raw body
More non-breaking hardening from the 3.2.2 audit:
- Backups copy a closed plugin.db's -wal/-shm sidecars. A plugin with no
open handle has no writer, so the sidecars are a consistent set with the
.db; without them an unclean shutdown's committed-but-uncheckpointed WAL
rows would be missing from the backup. The open-handle path still captures
a consistent VACUUM INTO snapshot and skips the sidecars.
- files.getContent reads off the event loop (fs.promises.readFile) instead
of a synchronous up-to-10MB read + base64 that stalled every other plugin
RPC and request for its duration.
- Plugin discovery only follows dev-link symlinks when TREK_PLUGINS_DEV_LINK
is set, so a stale link left on the volume is not registered on a normal
(non-dev) boot.
- Webhook (auth:false) proxy routes now receive req.rawBody, so a plugin can
verify a provider's HMAC signature over the exact payload it was computed
on. Enabled app-wide via Nest's rawBody option; authenticated routes never
receive the raw bytes.
* fix(build): move tz-lookup to server production dependencies
tripService imports tz-lookup at runtime (ICS VTIMEZONE resolution, #1453)
but it was declared in devDependencies. Local dev worked because the
monorepo hoists devDependencies into the root node_modules, but the
production Docker stage installs with `npm ci --omit=dev`, so the image
shipped without tz-lookup and the server crash-looped with
MODULE_NOT_FOUND on boot. Move it to dependencies and sync the lockfile.
* fix(dashboard): round the boarding-pass wrapper so its mask layer isn't square
The #1459 Safari fix split the pass into an outer wrapper (left notch) and the
inner glass panel (right notch). The wrapper carries a mask + translateZ, which
promotes it to its own compositing layer whose bounds stayed square, showing a
square edge over the rounded inner panel. Give the wrapper the same 24px radius.
* fix(plugins): refresh the active-plugin store on admin changes
Activating/deactivating a plugin only updated the admin panel's own rows, not the
app-wide active-plugin store the dashboard reads. So a freshly enabled widget/hero
plugin (e.g. Koffi) only appeared after a full reload. refresh() now also reloads
the plugin store, so consumers pick the change up live.
* fix(trips): refresh the roster after a guest/member change
Adding an accountless guest (or any member change) only refreshed the members
modal's own list; the planner's tripMembers — which Costs participants, Collab,
etc. read from — was stale until a full reload. Expose refreshMembers from the
planner and let the members modal call it after each roster mutation.
* fix(security): hide co-members' private packing items in summary and share
getTripSummary built its packing section with listPackingItems(tripId) and no
viewer id, so the per-viewer #858 privacy filter never ran — the MCP
get_trip_summary tool then returned every other member's private/personal
packing items (name, category, owner) to any trip member or packing-scoped
token. Thread the acting user through so the filter applies. Separately, the
public trip share loaded packing with a raw SELECT * and published it when
share_packing was on; a public viewer is neither owner nor recipient, so only
Common (is_private = 0) items may surface.
* fix(plugins): forward webhook raw body as base64 and bound the ICS tz cache
The inbound webhook raw body was forwarded as a UTF-8 string, which corrupts a
non-UTF-8 signed payload (U+FFFD substitution) so a plugin can't reconstruct the
exact bytes for HMAC verification; forward base64 (rawBodyBase64) instead. Also
bound the ICS timezone-validity cache: its key is a free-form plugin/importer
zone string, so cap the distinct entries rather than growing for the process
lifetime.
* fix(atlas): resolve to a real neighbour polygon before a loose fallback box
A polygon-less micro-territory box (PS, XK) is far larger than the enclave it
represents and was winning the smallest-box tie-break over the sovereign whose
polygon actually contains the point, so cities inside a neighbouring country
resolved to the wrong territory. Only a tight box now auto-wins; a loose one
defers behind real polygons and is used only when no polygon contains the point.
* fix(feeds): include shared member trips in the all-trips calendar feed
buildUserIcs selected owned trips only, so a user's aggregate feed silently
dropped every trip shared with them as a member. Match the single-trip feed's
membership check.
* fix(plugins): tighten the OAuth pre-check and backup restore/snapshot
OAuth token-endpoint fast-fail now also rejects IPv6-literal loopback/metadata
hosts and .internal names (LAN stays reachable for a self-hosted IdP). Backup
restore enforces the decompressed-size cap by counting extracted bytes instead
of trusting the archive's declared sizes, and refuses a zip-slip path. A plugin
snapshot whose fold-in fails now keeps its -wal/-shm so no committed data is lost.
* fix(plugins): validate bag roster, reap orphaned erasures, harden brokers
A packing bag can only be assigned to a member of its trip. Queued user-erasures
for an uninstalled plugin are reaped instead of lingering forever. GDPR export
folds in host-side settings/oauth presence (best-effort). notify.send builds its
link by URL-parse so a backslash can't slip an off-site redirect through, and
dev-link discovery skips a Windows junction unless dev-link mode is on.
* fix(trips): refresh trip members only after a mutation, not on modal open
loadMembers notified the planner on every call, including the open effect, firing
a redundant refresh. Only mutation handlers notify now.
* docs(wiki): document the 3.3.0 plugin platform surface
Cover the plugin activity log, widget slots and tab takeover, the notify/ai/oauth
brokers, provider hooks, scheduler, GDPR hooks, the new plugin env vars, plugin
contributions on the map/atlas/PDF/journal pages, plugin runtime hardening, and
the Budget->Costs rename with multi-currency and CSV export.
---------
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
This commit is contained in:
@@ -38,7 +38,10 @@ import { setupApiDocs } from './nest/platform/api-docs';
|
||||
* TrekExceptionFilter (also APP_FILTER).
|
||||
*/
|
||||
export async function buildApp(): Promise<INestApplication> {
|
||||
const app = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||
// rawBody keeps the unparsed request bytes on req.rawBody so a plugin webhook
|
||||
// route can verify a provider's HMAC signature over the exact payload (the
|
||||
// parsed JSON alone can't be re-serialised byte-for-byte).
|
||||
const app = await NestFactory.create(AppModule, new ExpressAdapter(), { rawBody: true });
|
||||
const instance = app.getHttpAdapter().getInstance();
|
||||
applyGlobalMiddleware(instance, { bodyParser: false });
|
||||
applyPlatformUploads(instance);
|
||||
|
||||
@@ -3483,6 +3483,106 @@ function runMigrations(db: Database.Database): void {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
// Per-user plugin settings (#plugins). A plugin can declare `scope:'user'`
|
||||
// settings fields (e.g. an API key or a personal preference); each USER stores
|
||||
// their own values here, separate from the admin-owned instance `plugins.config`.
|
||||
// Secrets are encrypted with the same apiKeyCrypto as instance secrets and are
|
||||
// never echoed back to the client (masked). Runtime reads the acting user's row.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_user_config (
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (plugin_id, user_id)
|
||||
);
|
||||
`);
|
||||
},
|
||||
// Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a
|
||||
// third-party service; the HOST runs authorize->callback->token->refresh with
|
||||
// PKCE+state and owns the tokens — the plugin never sees the refresh token. Tokens
|
||||
// are per-user + encrypted at rest; the in-flight PKCE verifier/state is short-lived.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_oauth_tokens (
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at INTEGER,
|
||||
scope TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (plugin_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS plugin_oauth_state (
|
||||
state TEXT PRIMARY KEY,
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
verifier TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
// Persistent plugin scheduler (#plugins). A plugin with the existing `jobs:run`
|
||||
// grant can schedule a userless callback to fire at a future time (once, or
|
||||
// recurring), surviving server restarts because the entry lives here. Same risk
|
||||
// class as cron jobs (no user, no trip reads, own db + declared egress only) —
|
||||
// so it rides on `jobs:run`, no new consent. UNIQUE(plugin_id, name) makes
|
||||
// scheduler.set an upsert and cancel deterministic. Rows are purged on uninstall.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_scheduled_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
due_at INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT 'null',
|
||||
every_ms INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (plugin_id, name)
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_sched_due ON plugin_scheduled_tasks (due_at);');
|
||||
},
|
||||
// Durable GDPR erasure queue (#plugins). When a TREK account is deleted, every
|
||||
// installed plugin holding `hook:user-data` gets a pending row here so its own
|
||||
// deleteUserData handler runs even if the plugin was offline at delete time —
|
||||
// erasure must not be lost across a restart, so it is persisted (unlike the
|
||||
// best-effort event buffer). The row is removed once the plugin acknowledges,
|
||||
// and all of a plugin's rows are purged on uninstall. UNIQUE(plugin_id, user_id)
|
||||
// makes re-enqueue idempotent.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_user_erasure_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (plugin_id, user_id)
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_erasure_plugin ON plugin_user_erasure_queue (plugin_id);');
|
||||
},
|
||||
|
||||
// Tombstones for Atlas countries the user has explicitly removed (#1490).
|
||||
// Atlas derives visited countries from trip places and transport endpoints on every
|
||||
// request, so those countries have no row to delete — "Remove" deleted from
|
||||
// visited_countries (which never had the row), the client hid it optimistically, and
|
||||
// the next getStats re-derived it. Recording the removal here lets getStats suppress
|
||||
// a derived country. Re-marking a country deletes its tombstone.
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS hidden_countries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
country_code TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, country_code)
|
||||
);
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id);');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setParticipants as setAssignmentParticipants,
|
||||
} from '../../services/assignmentService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -43,6 +44,7 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const assignment = createAssignment(dayId, placeId, notes || null);
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
@@ -66,6 +68,7 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
deleteAssignment(assignmentId);
|
||||
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
@@ -94,6 +97,7 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
end_time !== undefined ? end_time : (existing as any).assignment_end_time
|
||||
);
|
||||
safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
@@ -119,6 +123,7 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
|
||||
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
|
||||
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok({ assignment: result.assignment });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../services/assignmentService';
|
||||
import { onPlaceDeleted } from '../../services/journeyService';
|
||||
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
@@ -92,6 +92,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
|
||||
try { reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
|
||||
@@ -152,6 +153,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
|
||||
try { onPlaceDeleted(placeId); } catch {} // sync journeys before the row is gone
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId });
|
||||
|
||||
@@ -42,7 +42,7 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = getTripSummary(tripId);
|
||||
const summary = getTripSummary(tripId, userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
|
||||
}
|
||||
@@ -91,7 +91,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
const lines = Object.entries(grouped).map(([cat, items]) =>
|
||||
`## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
|
||||
).join('\n\n');
|
||||
const { trip } = getTripSummary(tripId) || {};
|
||||
const { trip } = getTripSummary(tripId, userId) || {};
|
||||
return {
|
||||
description: `Packing list for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
|
||||
@@ -112,7 +112,7 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = getTripSummary(tripId);
|
||||
const summary = getTripSummary(tripId, userId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const summary = getTripSummary(tripId);
|
||||
const summary = getTripSummary(tripId, userId);
|
||||
if (!summary) return noAccess();
|
||||
// Addon availability gates
|
||||
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
|
||||
@@ -181,8 +181,20 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
if (collabFeatures?.chat) messageCount = countMessages(tripId);
|
||||
}
|
||||
const notice = getDeprecationNotice();
|
||||
// The core bucket (trip metadata, members WITH email, days with place
|
||||
// coordinates, accommodations) carries confidential PII and itinerary data,
|
||||
// so it is gated on trips:read just like the sub-sections below. Without a
|
||||
// read scope the tool still resolves trip id + title so it stays usable for
|
||||
// navigation (list_trips already covers discovery). trek_ PATs (null scopes)
|
||||
// and any trips:read holder keep the full payload — no behaviour change.
|
||||
const summaryData = {
|
||||
...summary,
|
||||
trip: R ? summary.trip : { id: summary.trip.id, title: summary.trip.title },
|
||||
members: R ? summary.members : undefined,
|
||||
days: R ? summary.days : undefined,
|
||||
// Accommodations are "accommodation details" under reservations:read too
|
||||
// (see SCOPE_INFO) and pair with reservations in the share payload, so a
|
||||
// reservations-scoped token keeps them — gate on either read scope.
|
||||
accommodations: (R || canReadRes) ? summary.accommodations : undefined,
|
||||
reservations: canReadRes ? summary.reservations : undefined,
|
||||
packing: canReadPacking ? summary.packing : undefined,
|
||||
budget: canReadBudget ? summary.budget : undefined,
|
||||
|
||||
@@ -38,7 +38,7 @@ function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void {
|
||||
* Byte-identical to the legacy Express route (server/src/routes/assignments.ts):
|
||||
* trip access (404), 'day_edit' on mutations (403, GET is access-only), create
|
||||
* 201 / rest 200, the bespoke "Day not found" / "Place not found" / "Assignment
|
||||
* not found" bodies, the journey place-created hook, and WebSocket broadcasts.
|
||||
* not found" bodies, the journey skeleton reconcile, and WebSocket broadcasts.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/days/:dayId/assignments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -72,7 +72,7 @@ export class DayAssignmentsController {
|
||||
}
|
||||
const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes);
|
||||
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
|
||||
this.assignments.notifyPlaceCreated(tripId, body.place_id);
|
||||
this.assignments.reconcile(tripId, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export class DayAssignmentsController {
|
||||
}
|
||||
this.assignments.deleteAssignment(id);
|
||||
this.assignments.broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, socketId);
|
||||
this.assignments.reconcile(tripId, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -142,6 +143,7 @@ export class AssignmentOpsController {
|
||||
const oldDayId = (existing as { day_id: number }).day_id;
|
||||
const { assignment } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index, oldDayId);
|
||||
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
|
||||
this.assignments.reconcile(tripId, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@@ -166,6 +168,7 @@ export class AssignmentOpsController {
|
||||
}
|
||||
const assignment = this.assignments.updateTime(id, body.place_time, body.end_time);
|
||||
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
||||
this.assignments.reconcile(tripId, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import { canAccessTrip } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/assignmentService';
|
||||
import { onPlaceCreated } from '../../services/journeyService';
|
||||
import { reconcileTripSkeletons } from '../../services/journeyService';
|
||||
|
||||
type Trip = { user_id: number };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing assignment service. Trip access mirrors
|
||||
* the requireTripAccess middleware (canAccessTrip); mutations use 'day_edit'.
|
||||
* The SQL, the move/reorder logic and the journey "place created" hook reuse the
|
||||
* The SQL, the move/reorder logic and the journey skeleton reconcile reuse the
|
||||
* legacy code unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
@@ -44,9 +44,13 @@ export class AssignmentsService {
|
||||
return svc.createAssignment(dayId, placeId as never, notes as never);
|
||||
}
|
||||
|
||||
/** Mirrors the legacy POST hook; non-fatal, like the route's try/catch. */
|
||||
notifyPlaceCreated(tripId: string, placeId: unknown): void {
|
||||
try { onPlaceCreated(Number(tripId), Number(placeId)); } catch { /* non-fatal */ }
|
||||
/**
|
||||
* Re-mirror the trip's day-assigned places onto every linked journey's skeleton
|
||||
* suggestions. Called after any assignment mutation (create/delete/move/time) so
|
||||
* the journey stays in sync. Non-fatal, like the route's try/catch.
|
||||
*/
|
||||
reconcile(tripId: string, socketId?: string): void {
|
||||
try { reconcileTripSkeletons(Number(tripId), socketId); } catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
assignmentExistsInDay(id: string, dayId: string, tripId: string) {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class CollabController {
|
||||
deleteNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('fileId') fileId: string, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!this.collab.deleteNoteFile(id, fileId)) {
|
||||
if (!this.collab.deleteNoteFile(tripId, id, fileId)) {
|
||||
throw new HttpException({ error: 'File not found' }, 404);
|
||||
}
|
||||
this.collab.broadcast(tripId, 'collab:note:updated', { note: this.collab.getFormattedNoteById(id) }, socketId);
|
||||
|
||||
@@ -36,7 +36,7 @@ export class CollabService {
|
||||
deleteNote(tripId: string, id: string): boolean { return svc.deleteNote(tripId, id); }
|
||||
addNoteFile(tripId: string, id: string, file: Parameters<typeof svc.addNoteFile>[2]) { return svc.addNoteFile(tripId, id, file); }
|
||||
getFormattedNoteById(id: string) { return svc.getFormattedNoteById(id); }
|
||||
deleteNoteFile(id: string, fileId: string): boolean { return svc.deleteNoteFile(id, fileId); }
|
||||
deleteNoteFile(tripId: string, noteId: string, fileId: string): boolean { return svc.deleteNoteFile(tripId, noteId, fileId); }
|
||||
|
||||
listPolls(tripId: string) { return svc.listPolls(tripId); }
|
||||
createPoll(tripId: string, userId: number, data: Parameters<typeof svc.createPoll>[2]) { return svc.createPoll(tripId, userId, data); }
|
||||
|
||||
@@ -107,35 +107,47 @@ export class FeedsService {
|
||||
if (!user) return null;
|
||||
|
||||
const cutoff = ninetyDaysAgo();
|
||||
// "All Trips" means every trip the user can open — trips they own AND trips shared with
|
||||
// them as a member — mirroring the single-trip feed's access (tripTokenRow/assertAccess).
|
||||
// A membership WHERE on trips selects each row once, so owned + member trips don't dupe.
|
||||
const trips = db
|
||||
.prepare(
|
||||
`SELECT id FROM trips
|
||||
WHERE user_id = ?
|
||||
WHERE (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))
|
||||
AND is_archived = 0
|
||||
AND (end_date IS NULL OR end_date >= ?)
|
||||
ORDER BY start_date ASC`,
|
||||
)
|
||||
.all(user.id, cutoff) as { id: number }[];
|
||||
.all(user.id, user.id, cutoff) as { id: number }[];
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
|
||||
const calName = `${user.username} – All Trips`;
|
||||
let combined =
|
||||
let header =
|
||||
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
combined += `X-WR-CALNAME:${esc(calName)}\r\n`;
|
||||
combined += 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n';
|
||||
header += `X-WR-CALNAME:${esc(calName)}\r\n`;
|
||||
header += 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n';
|
||||
|
||||
// VTIMEZONE blocks are deduped by TZID across all trips and emitted once in
|
||||
// the combined header, before any VEVENT, so per-trip TZID references still
|
||||
// resolve after extractVEvents strips everything but the events (#1453).
|
||||
const zones = new Map<string, string>();
|
||||
let events = '';
|
||||
for (const { id } of trips) {
|
||||
try {
|
||||
const { ics } = exportICS(id);
|
||||
combined += extractVEvents(ics);
|
||||
for (const vtz of extractVTimezones(ics)) {
|
||||
const tzid = vtz.match(/\r\nTZID:(.+)\r\n/)?.[1];
|
||||
if (tzid && !zones.has(tzid)) zones.set(tzid, vtz);
|
||||
}
|
||||
events += extractVEvents(ics);
|
||||
} catch {
|
||||
// skip failed trips
|
||||
}
|
||||
}
|
||||
|
||||
combined += 'END:VCALENDAR\r\n';
|
||||
const combined = header + [...zones.values()].join('') + events + 'END:VCALENDAR\r\n';
|
||||
return { ics: combined, calName };
|
||||
}
|
||||
}
|
||||
@@ -156,3 +168,24 @@ function extractVEvents(ics: string): string {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pull out each VTIMEZONE block (same structural line scan as extractVEvents) so
|
||||
// the combined all-trips feed can carry the zone definitions its events' TZID
|
||||
// parameters reference.
|
||||
function extractVTimezones(ics: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let current = '';
|
||||
let inside = false;
|
||||
for (const line of ics.split('\r\n')) {
|
||||
if (line === 'BEGIN:VTIMEZONE') {
|
||||
inside = true;
|
||||
current = '';
|
||||
}
|
||||
if (inside) current += line + '\r\n';
|
||||
if (line === 'END:VTIMEZONE') {
|
||||
inside = false;
|
||||
blocks.push(current);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
const MAX_TOKENS = 8192;
|
||||
@@ -48,7 +49,9 @@ export class AnthropicClient implements LlmExtractionClient {
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
// baseUrl is user-configurable — guard it against pointing at the cloud
|
||||
// metadata endpoint, while still allowing a local/LAN gateway.
|
||||
res = await safeFetchLlm(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
|
||||
// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then
|
||||
// take a few minutes on a longer document before the first token.
|
||||
@@ -66,7 +67,9 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
// baseUrl is user-configurable — guard it against pointing at the cloud
|
||||
// metadata endpoint, while still allowing a local/LAN Ollama.
|
||||
res = await safeFetchLlm(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable, HttpException } from '@nestjs/common';
|
||||
import { safeFetchLlm } from '../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
|
||||
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
|
||||
* the server root — not the `/v1` OpenAI-compatible path the extraction client
|
||||
* uses. Admin-only (guarded at the controller); the base URL is admin-supplied
|
||||
* and typically points at a localhost Ollama, so SSRF guarding is intentionally
|
||||
* not applied (it would block localhost) — we only validate the protocol.
|
||||
* uses. Admin-only (guarded at the controller); the base URL is admin-supplied.
|
||||
* Requests go through safeFetchLlm, which still allows a localhost/LAN Ollama but
|
||||
* blocks the link-local / cloud-metadata range.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LlmLocalService {
|
||||
@@ -30,7 +31,7 @@ export class LlmLocalService {
|
||||
const root = this.ollamaRoot(baseUrl);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${root}/api/tags`, { signal: AbortSignal.timeout(10_000) });
|
||||
res = await safeFetchLlm(`${root}/api/tags`, { signal: AbortSignal.timeout(10_000) });
|
||||
} catch {
|
||||
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export class LlmLocalService {
|
||||
const root = this.ollamaRoot(baseUrl);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${root}/api/pull`, {
|
||||
res = await safeFetchLlm(`${root}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: model.trim(), stream: true }),
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* tool/response_format and keep using the existing clients.)
|
||||
*/
|
||||
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
|
||||
const TIMEOUT_MS = 300_000;
|
||||
|
||||
export interface EnforcedExtractInput {
|
||||
@@ -71,7 +73,9 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
// baseUrl is user-configurable — guard it against the cloud-metadata range,
|
||||
// while still allowing a local/LAN Ollama.
|
||||
res = await safeFetchLlm(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/atlas-layers — country tint layers plugins draw over the Atlas world
|
||||
* map via the `atlasLayerProvider` hook (wishlists, travel advisories, …).
|
||||
* USER-SCOPED: there is no tripId — the layers are for the signed-in user, and
|
||||
* the hook receives no target parameter (the acting user is host-bound to the
|
||||
* invocation, so the plugin cannot ask for anyone else's map). No addon gate,
|
||||
* matching the other /api/addons/atlas routes, which have none either.
|
||||
*
|
||||
* DECLARATIVE ONLY — a plugin never touches the map canvas; it returns country
|
||||
* codes + a tone the host tints. Everything is normalized here: codes must be
|
||||
* ISO-3166 alpha-2 (uppercase-coerced, anything else dropped), tone is
|
||||
* enum-whitelisted, strings are String()-coerced + length-capped, and the
|
||||
* layer/country counts are capped per plugin.
|
||||
*/
|
||||
type Tone = 'default' | 'success' | 'warn' | 'danger';
|
||||
interface AtlasLayerCountry {
|
||||
code: string;
|
||||
tone: Tone;
|
||||
label?: string;
|
||||
}
|
||||
interface AtlasLayer {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
countries: AtlasLayerCountry[];
|
||||
}
|
||||
|
||||
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
|
||||
const CODE_RE = /^[A-Z]{2}$/;
|
||||
const MAX_LAYERS = 3; // per provider
|
||||
const MAX_COUNTRIES = 300; // per layer — bounds the render cost
|
||||
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
|
||||
|
||||
function normalizeCountries(raw: unknown): AtlasLayerCountry[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: AtlasLayerCountry[] = [];
|
||||
for (const c of list) {
|
||||
if (out.length >= MAX_COUNTRIES) break;
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
const code = String(c.code ?? '').toUpperCase();
|
||||
if (!CODE_RE.test(code)) continue; // not an alpha-2 code — nothing to tint
|
||||
out.push({
|
||||
code,
|
||||
// Check the raw value (not String(c.tone)) so a non-string tone can't slip through
|
||||
// its toString() and be emitted raw through the Tone-typed API.
|
||||
tone: TONES.has(c.tone as string) ? (c.tone as Tone) : 'default',
|
||||
label: c.label != null ? cap(c.label, 80) : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalize(pluginId: string, raw: unknown): AtlasLayer[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: AtlasLayer[] = [];
|
||||
for (const l of list) {
|
||||
if (out.length >= MAX_LAYERS) break;
|
||||
if (!l || typeof l !== 'object') continue;
|
||||
const id = cap(l.id, 64);
|
||||
if (!id) continue; // a layer with no id can't be keyed/deduped — drop it
|
||||
out.push({
|
||||
pluginId,
|
||||
id,
|
||||
name: l.name != null ? cap(l.name, 60) : undefined,
|
||||
countries: normalizeCountries(l.countries),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/atlas-layers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AtlasLayersController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get()
|
||||
async get(@Req() req: Request & { user?: { id: number } }): Promise<{ layers: AtlasLayer[] }> {
|
||||
if (!pluginsEnabled()) return { layers: [] };
|
||||
const userId = req.user?.id;
|
||||
if (userId == null) return { layers: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('atlasLayerProvider');
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<AtlasLayer[]> => {
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(id, 'atlasLayerProvider', 'getLayers', [], userId, 5000);
|
||||
return normalize(id, raw);
|
||||
} catch {
|
||||
return []; // a slow / failing provider contributes nothing
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { layers: perProvider.flat() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Dev-link: register a plugin from a LOCAL build directory and hot-reload it
|
||||
* against a real instance's data (#plugins, developer experience).
|
||||
*
|
||||
* This is an explicit, opt-in DEVELOPMENT capability and must NEVER be reachable
|
||||
* in production. A dev-linked plugin (a) bypasses the install-time signature /
|
||||
* integrity model — its code mutates live between restarts with no re-consent —
|
||||
* and (b) under `npm run dev` runs with the OS permission jail OFF. Its DATA access
|
||||
* is still fully gated by the capability RPC host (grants + the acting user's real
|
||||
* membership, no impersonation) exactly like any other plugin — code origin never
|
||||
* touches that gate — but the two properties above are why loading unsigned local
|
||||
* code must be gated behind an explicit flag on top of the admin + kill-switch gates.
|
||||
*
|
||||
* Enable ONLY by setting `TREK_PLUGINS_DEV_LINK=1`. Off (absent/any other value)
|
||||
* by default, so it can never be silently on in a shared or production deployment.
|
||||
*/
|
||||
export function devLinkEnabled(): boolean {
|
||||
return process.env.TREK_PLUGINS_DEV_LINK === '1';
|
||||
}
|
||||
|
||||
/** Provenance marker stamped on a dev-linked plugin row (free-text `source_repo`). */
|
||||
export const DEV_LINK_SOURCE = 'local:link';
|
||||
@@ -1,17 +1,42 @@
|
||||
import { db, canAccessTrip } from '../../../db/database';
|
||||
import { broadcast, broadcastToUser } from '../../../websocket';
|
||||
import { listBudgetItems } from '../../../services/budgetService';
|
||||
import { listItems as listPackingItemsSvc } from '../../../services/packingService';
|
||||
import { listFiles } from '../../../services/fileService';
|
||||
import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, updateItem as updatePackingItemSvc, deleteItem as deletePackingItemSvc, listBags, createBag as createBagSvc, updateBag as updateBagSvc, deleteBag as deleteBagSvc, setBagMembers } from '../../../services/packingService';
|
||||
import { isUpdateConflict } from '../../../services/conflictResult';
|
||||
import { getWeather } from '../../../services/weatherService';
|
||||
import { listCategories } from '../../../services/categoryService';
|
||||
import { listTags, createTag, updateTag, deleteTag, getTagByIdAndUser } from '../../../services/tagService';
|
||||
import { listItems as listTodosSvc, createItem as createTodoSvc, updateItem as updateTodoSvc, deleteItem as deleteTodoSvc } from '../../../services/todoService';
|
||||
import { listFiles, createFile, createFileLink, getFileById, updateFile, softDeleteFile, findForeignLinkTarget, resolveFilePath, BLOCKED_EXTENSIONS, filesDir } from '../../../services/fileService';
|
||||
import { createNote as createCollabNoteSvc, createPoll as createCollabPollSvc, votePoll as voteCollabPollSvc, createMessage as createCollabMessageSvc, listNotes as listCollabNotesSvc, listPolls as listCollabPollsSvc, listMessages as listCollabMessagesSvc } from '../../../services/collabService';
|
||||
import { getRates as getExchangeRates } from '../../../services/exchangeRateService';
|
||||
import { joinTripAsMember } from '../../../services/tripMembership';
|
||||
import { send as sendNotification } from '../../../services/notificationService';
|
||||
import { resolveLlmConfig } from '../../llm-parse/llm-config.resolver';
|
||||
import { createLlmClient } from '../../llm-parse/llm-client.factory';
|
||||
import { readUserSettingDecrypted } from '../plugins.service';
|
||||
import { PluginOAuthService } from '../plugin-oauth.service';
|
||||
import fsMod from 'node:fs';
|
||||
import pathMod from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { checkPermission } from '../../../services/permissions';
|
||||
import { listTrips, updateTrip, NotFoundError, ValidationError } from '../../../services/tripService';
|
||||
import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService';
|
||||
import { createPlace, updatePlace, deletePlace } from '../../../services/placeService';
|
||||
import { createDay, getDay, updateDay, deleteDay } from '../../../services/dayService';
|
||||
import { createDay, getDay, updateDay, deleteDay, listDays, listAccommodations, validateAccommodationRefs, createAccommodation as createAccommodationSvc, getAccommodation, updateAccommodation as updateAccommodationSvc, deleteAccommodation as deleteAccommodationSvc } from '../../../services/dayService';
|
||||
import { createAssignment, deleteAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../services/assignmentService';
|
||||
import { isAddonEnabled } from '../../../services/adminService';
|
||||
import { isDemoEmail } from '../../../services/demo';
|
||||
import { ADDON_IDS } from '../../../addons';
|
||||
import { listJourneys, listEntries as listJournalEntriesSvc, createEntry as createJournalEntrySvc, updateEntry as updateJournalEntrySvc, deleteEntry as deleteJournalEntrySvc, createJourney as createJourneySvc, deleteJourney as deleteJourneySvc } from '../../../services/journeyService';
|
||||
import { listVisitedCountries, listManuallyVisitedRegions, listBucketList, markCountryVisited, unmarkCountryVisited, markRegionVisited, unmarkRegionVisited, createBucketItem as createBucketItemSvc, deleteBucketItem as deleteBucketItemSvc } from '../../../services/atlasService';
|
||||
import { getPlanData, getActivePlanId, toggleEntry as vacayToggleEntrySvc, toggleCompanyHoliday as vacayToggleCompanyHolidaySvc } from '../../../services/vacayService';
|
||||
import { listNotes, createNote, getNote, updateNote, deleteNote, dayExists as dayNoteDayExists } from '../../../services/dayNoteService';
|
||||
import { listCollections, getCollection, createCollection, updateCollection, savePlace as saveCollectionPlaceSvc, copyToTrip as copyCollectionToTripSvc, deletePlace as deleteCollectionPlaceSvc } from '../../../services/collectionsService';
|
||||
import { BudgetService } from '../../budget/budget.service';
|
||||
import { ReservationsService } from '../../reservations/reservations.service';
|
||||
import type { User } from '../../../types';
|
||||
import { PluginDataDb } from './plugin-data.service';
|
||||
import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget';
|
||||
import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
|
||||
import { appendAudit } from './plugin-audit';
|
||||
|
||||
@@ -32,6 +57,89 @@ function canEditTripAs(action: string, tripId: number, userId: number): boolean
|
||||
// Reused for costs.create so a plugin write frozen-FX and members/payers logic
|
||||
// matches a normal web-app budget write exactly (it has no injected deps).
|
||||
const budgetSvc = new BudgetService();
|
||||
// Same idea for reservations: the Nest service (no injected deps) encapsulates the
|
||||
// accommodation/budget-sync/notification side effects 1:1 with the REST controller.
|
||||
const reservationsSvc = new ReservationsService();
|
||||
|
||||
// The booking notification the REST controller sends after a create/update/delete
|
||||
// (services/notificationService via reservationsSvc). Needs the acting User; a plugin
|
||||
// only holds the id, so hydrate it host-side. Fire-and-forget — never blocks the write.
|
||||
function notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void {
|
||||
const actor = db.prepare('SELECT * FROM users WHERE id = ?').get(actingUserId) as User | undefined;
|
||||
if (actor) reservationsSvc.notifyBookingChange(String(tripId), actor, booking, type);
|
||||
}
|
||||
|
||||
// A subsystem read is refused when its addon is off — parity with the app, where a
|
||||
// disabled addon means there is simply nothing to read (same shape as the Costs gate).
|
||||
function requireAddon(addonId: string, noun: string): void {
|
||||
if (!isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`);
|
||||
}
|
||||
|
||||
// collectionsService self-gates per-collection role by THROWING status-tagged errors
|
||||
// (assertAccess 404, assertCanEdit 403, validation 400). Map them onto the RPC error
|
||||
// classes so a plugin sees RESOURCE_FORBIDDEN / BAD_PARAMS instead of HOST_ERROR.
|
||||
function mapCollectionError<T>(fn: () => T): T {
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
const status = (e as { status?: number })?.status;
|
||||
const msg = e instanceof Error ? e.message : 'collection error';
|
||||
if (status === 403 || status === 404) throw new ForbiddenResource(msg);
|
||||
if (status === 400 || status === 409) throw new BadParams(msg);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- #858 packing privacy: viewer-scoped fan-out replicated from packing.controller +
|
||||
// packing.service (their helpers aren't exported). A private item's events reach ONLY
|
||||
// its owner (+ recipients for a shared item), never the whole trip room; passing the
|
||||
// wrong onlyUserId — or forgetting to drop a freshly-privatized item from the room —
|
||||
// leaks it. Keep in lockstep with packing.controller.broadcastUpdate/emitToViewers. ---
|
||||
type PackingPrivacy = { is_private?: number; owner_id?: number | null; recipients?: { user_id: number }[] };
|
||||
|
||||
function packingItemPrivacy(tripId: number, itemId: number): { is_private?: number; owner_id?: number | null } | undefined {
|
||||
return db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as
|
||||
| { is_private?: number; owner_id?: number | null }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function packingViewersOf(item: PackingPrivacy | null | undefined): number[] | null {
|
||||
if (!item || !item.is_private) return null; // Common — visible to the whole room
|
||||
return [item.owner_id, ...(item.recipients || []).map((r) => r.user_id)].filter((x): x is number => x != null);
|
||||
}
|
||||
|
||||
/** CREATE/DELETE fan-out: whole room for a Common item, else owner + recipients only. */
|
||||
function emitPackingToViewers(tripId: number, event: string, payload: Record<string, unknown>, item: PackingPrivacy): void {
|
||||
const viewers = packingViewersOf(item);
|
||||
if (viewers === null) {
|
||||
broadcast(tripId, event, payload, undefined);
|
||||
return;
|
||||
}
|
||||
for (const uid of new Set(viewers)) if (uid != null) broadcast(tripId, event, payload, undefined, uid);
|
||||
}
|
||||
|
||||
/** An item event delivered owner-only when the item is private (else to the room). */
|
||||
function broadcastPackingItem(tripId: number, event: string, payload: Record<string, unknown>, item: PackingPrivacy): void {
|
||||
const onlyUserId = item?.is_private && item.owner_id != null ? item.owner_id : undefined;
|
||||
broadcast(tripId, event, payload, undefined, onlyUserId);
|
||||
}
|
||||
|
||||
/** The four public<->private transitions (packing.controller.broadcastUpdate). `wasPrivate`
|
||||
* is read BEFORE the write — getting this wrong LEAKS a freshly-privatized item. */
|
||||
function broadcastPackingUpdate(tripId: number, itemId: number, item: PackingPrivacy, wasPrivate: boolean): void {
|
||||
const nowPrivate = !!item.is_private;
|
||||
if (nowPrivate) {
|
||||
if (wasPrivate) {
|
||||
broadcastPackingItem(tripId, 'packing:updated', { item }, item); // stays private -> owner-only
|
||||
} else {
|
||||
broadcast(tripId, 'packing:deleted', { itemId }, undefined); // public->private: drop from the room...
|
||||
broadcastPackingItem(tripId, 'packing:created', { item }, item); // ...then re-add for the owner
|
||||
}
|
||||
} else {
|
||||
if (wasPrivate) broadcast(tripId, 'packing:created', { item }, undefined); // private->public: add for members who lacked it
|
||||
broadcast(tripId, 'packing:updated', { item }, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Quotas for plugin entity metadata (db:meta) — a cheap disk-DoS guard on the
|
||||
// shared trek.db volume. Generous for real use, small enough to bound abuse.
|
||||
@@ -50,7 +158,12 @@ const dataDbs = new Map<string, PluginDataDb>();
|
||||
|
||||
export function getPluginDataDb(id: string): PluginDataDb {
|
||||
let d = dataDbs.get(id);
|
||||
if (!d) {
|
||||
// A cached handle can be CLOSED without being evicted: the supervisor's terminal
|
||||
// failure paths (activation timeout / load-error / crash auto-disable) call
|
||||
// rpcHost.dispose() → PluginDataDb.close() directly, never closePluginDataDb. A
|
||||
// plain admin re-enable would then reuse the closed handle and every db.* call
|
||||
// would throw 'database connection is not open'. Recreate when the handle is shut.
|
||||
if (!d || !d.isOpen()) {
|
||||
d = new PluginDataDb(id);
|
||||
dataDbs.set(id, d);
|
||||
}
|
||||
@@ -60,6 +173,36 @@ export function getPluginDataDb(id: string): PluginDataDb {
|
||||
export function closePluginDataDb(id: string): void {
|
||||
dataDbs.get(id)?.close();
|
||||
dataDbs.delete(id);
|
||||
budgets.delete(id);
|
||||
}
|
||||
|
||||
// Per-plugin daily broker budgets (ai/notify). Lazily created + seeded from the
|
||||
// local capability audit — which already records every ai/notify call today — so a
|
||||
// restart continues the same UTC day instead of resetting the budget. In-memory,
|
||||
// nothing persisted or phoned home.
|
||||
const budgets = new Map<string, DailyBudget>();
|
||||
function budgetFor(id: string): DailyBudget {
|
||||
let b = budgets.get(id);
|
||||
if (!b) {
|
||||
const now = Date.now();
|
||||
const since = new Date(now).toISOString().slice(0, 10) + 'T00:00:00';
|
||||
const rows = db
|
||||
.prepare("SELECT method, COUNT(*) AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY method")
|
||||
.all(id, since) as Array<{ method: string; n: number }>;
|
||||
let ai = 0, notify = 0;
|
||||
for (const r of rows) {
|
||||
if (r.method === 'notify.send') notify += r.n;
|
||||
else ai += r.n; // ai.complete + ai.extract
|
||||
}
|
||||
b = new DailyBudget(DEFAULT_DAILY_BUDGET, now, { ai, notify });
|
||||
budgets.set(id, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Today's broker usage for one plugin (admin view). Seeds the counter if unseen. */
|
||||
export function pluginBudgetUsage(id: string): ReturnType<DailyBudget['used']> {
|
||||
return budgetFor(id).used(Date.now());
|
||||
}
|
||||
|
||||
/** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */
|
||||
@@ -70,7 +213,15 @@ export interface PluginCallRouter {
|
||||
|
||||
export function createRealRpcHost(id: string, granted: ReadonlySet<string>, router: PluginCallRouter): PluginRpcHost {
|
||||
return new PluginRpcHost(id, granted, {
|
||||
data: getPluginDataDb(id),
|
||||
// Resolve the data handle lazily on every access rather than capturing it once.
|
||||
// disable()/uninstall() drop the entry from `running` BEFORE awaiting the kill grace
|
||||
// and calling dispose(), so a re-enable in that window builds a NEW host. A captured
|
||||
// handle would let the OLD host's dispose() close the DB out from under the NEW one
|
||||
// (every db.* call then throws 'database connection is not open'). Resolving per call
|
||||
// means the new host always uses the current, open handle — getPluginDataDb recreates
|
||||
// one the moment a stale dispose closes it. Safe because db.* is synchronous, so no
|
||||
// call is ever mid-flight when a dispose from another tick closes the handle.
|
||||
get data() { return getPluginDataDb(id); },
|
||||
db,
|
||||
canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId),
|
||||
// The router binds this host's plugin id as the caller/source.
|
||||
@@ -106,6 +257,207 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
|
||||
// these just delegate to the same services the REST paths use. ---
|
||||
listPackingItems: (tripId, userId) => listPackingItemsSvc(tripId, userId),
|
||||
listTripFiles: (tripId) => listFiles(tripId, false),
|
||||
// A file's bytes as base64, size-capped BEFORE the read so a 500MB video can't
|
||||
// be pulled (~667MB base64) through the IPC pipe. 10MB matches the plugin upload
|
||||
// cap; trashed files (deleted_at set) are refused like the download path.
|
||||
getTripFileContent: async (tripId, fileId) => {
|
||||
const CONTENT_MAX = 10 * 1024 * 1024;
|
||||
const file = getFileById(fileId, tripId) as { filename: string; original_name: string; mime_type: string | null; file_size: number | null; deleted_at: string | null } | undefined;
|
||||
if (!file || file.deleted_at) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
if ((file.file_size ?? 0) > CONTENT_MAX) throw new BadParams(`file too large to read (>${CONTENT_MAX} bytes); use the download UI`);
|
||||
const { resolved, safe } = resolveFilePath(file.filename);
|
||||
if (!safe) throw new ForbiddenResource('file path is not accessible');
|
||||
// Read off the event loop — a 10MB read + base64 on the host thread would
|
||||
// otherwise stall every other plugin RPC and request for its duration.
|
||||
const buf = await fsMod.promises.readFile(resolved);
|
||||
if (buf.length > CONTENT_MAX) throw new BadParams('file too large to read');
|
||||
return { name: file.original_name, mimetype: file.mime_type ?? 'application/octet-stream', size: buf.length, content_base64: buf.toString('base64') };
|
||||
},
|
||||
// --- Files write. Bytes arrive as bounded base64; the extension is validated
|
||||
// against the central blocklist BEFORE anything touches disk, and link targets
|
||||
// must live on the same trip (findForeignLinkTarget). Same events as the app. ---
|
||||
canUploadFiles: (tripId, userId) => canEditTripAs('file_upload', tripId, userId),
|
||||
canEditFiles: (tripId, userId) => canEditTripAs('file_edit', tripId, userId),
|
||||
canDeleteFiles: (tripId, userId) => canEditTripAs('file_delete', tripId, userId),
|
||||
createTripFile: (tripId, input, actingUserId) => {
|
||||
// Mirror the REST upload guard (files.controller): a demo user must not write
|
||||
// bytes to the shared demo instance, even through a plugin's db:write:files.
|
||||
// Only resolve the email when demo mode is actually on — keeps the hot path
|
||||
// (and the schema surface) untouched for self-hosted installs.
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') {
|
||||
const uploader = db.prepare('SELECT email FROM users WHERE id = ?').get(actingUserId) as { email?: string } | undefined;
|
||||
if (isDemoEmail(uploader?.email)) throw new ForbiddenResource('Uploads are disabled in demo mode.');
|
||||
}
|
||||
const i = input as { name: string; content_base64: string; mimetype?: string; description?: string; place_id?: number; reservation_id?: number };
|
||||
const original = pathMod.basename(i.name);
|
||||
const ext = pathMod.extname(original).toLowerCase();
|
||||
if (!ext || BLOCKED_EXTENSIONS.includes(ext)) throw new BadParams(`file extension '${ext || '(none)'}' is not allowed`);
|
||||
const buf = Buffer.from(i.content_base64, 'base64');
|
||||
if (buf.length === 0) throw new BadParams('file content is empty');
|
||||
if (buf.length > 10 * 1024 * 1024) throw new BadParams('file exceeds the 10MB plugin upload cap');
|
||||
const foreign = findForeignLinkTarget(tripId, { reservation_id: i.reservation_id ?? null, place_id: i.place_id ?? null });
|
||||
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
fsMod.mkdirSync(filesDir, { recursive: true });
|
||||
fsMod.writeFileSync(pathMod.join(filesDir, filename), buf);
|
||||
const file = createFile(
|
||||
tripId,
|
||||
{ filename, originalname: original, size: buf.length, mimetype: i.mimetype || 'application/octet-stream' },
|
||||
actingUserId,
|
||||
{ place_id: i.place_id != null ? String(i.place_id) : null, reservation_id: i.reservation_id != null ? String(i.reservation_id) : null, description: i.description ?? null },
|
||||
);
|
||||
broadcast(tripId, 'file:created', { file }, undefined);
|
||||
return file;
|
||||
},
|
||||
createTripFileLink: (tripId, fileId, opts) => {
|
||||
if (!getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
const o = opts as { reservation_id?: number; assignment_id?: number; place_id?: number };
|
||||
const foreign = findForeignLinkTarget(tripId, o);
|
||||
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
|
||||
return createFileLink(fileId, { reservation_id: o.reservation_id != null ? String(o.reservation_id) : null, assignment_id: o.assignment_id != null ? String(o.assignment_id) : null, place_id: o.place_id != null ? String(o.place_id) : null });
|
||||
},
|
||||
updateTripFile: (tripId, fileId, input) => {
|
||||
const current = getFileById(fileId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
const i = input as { description?: string; place_id?: number | null; reservation_id?: number | null };
|
||||
const foreign = findForeignLinkTarget(tripId, { reservation_id: i.reservation_id ?? null, place_id: i.place_id ?? null });
|
||||
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
|
||||
const file = updateFile(fileId, current, { description: i.description, place_id: i.place_id != null ? String(i.place_id) : i.place_id === null ? null : undefined, reservation_id: i.reservation_id != null ? String(i.reservation_id) : i.reservation_id === null ? null : undefined });
|
||||
broadcast(tripId, 'file:updated', { file }, undefined);
|
||||
return file;
|
||||
},
|
||||
softDeleteTripFile: (tripId, fileId) => {
|
||||
if (!getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
softDeleteFile(fileId);
|
||||
broadcast(tripId, 'file:deleted', { fileId }, undefined);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Collab reads (collab addon; membership checked by the host). Same hydrated
|
||||
// shapes as the REST GETs, so a collab plugin can finally read what it writes. ---
|
||||
listCollabNotes: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return listCollabNotesSvc(tripId) as unknown[]; },
|
||||
listCollabPolls: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return listCollabPollsSvc(tripId) as unknown[]; },
|
||||
listCollabMessages: (tripId, before) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return listCollabMessagesSvc(tripId, before) as unknown[]; },
|
||||
// --- Collab content (collab addon). The services validate + self-report errors. ---
|
||||
canEditCollab: (tripId, userId) => canEditTripAs('collab_edit', tripId, userId),
|
||||
createCollabNote: (tripId, input, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const note = createCollabNoteSvc(String(tripId), actingUserId, input as never);
|
||||
broadcast(tripId, 'collab:note:created', { note }, undefined);
|
||||
return note;
|
||||
},
|
||||
createCollabPoll: (tripId, input, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const poll = createCollabPollSvc(String(tripId), actingUserId, input as never);
|
||||
broadcast(tripId, 'collab:poll:created', { poll }, undefined);
|
||||
return poll;
|
||||
},
|
||||
voteCollabPoll: (tripId, pollId, optionIndex, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const result = voteCollabPollSvc(String(tripId), String(pollId), actingUserId, optionIndex);
|
||||
if (result.error) throw new BadParams(result.error);
|
||||
broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, undefined);
|
||||
return result.poll;
|
||||
},
|
||||
createCollabMessage: (tripId, text, replyTo, actingUserId) => {
|
||||
requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
const result = createCollabMessageSvc(String(tripId), actingUserId, text, replyTo ?? null);
|
||||
if (result.error) throw new BadParams(result.error);
|
||||
broadcast(tripId, 'collab:message:created', { message: result.message }, undefined);
|
||||
return result.message;
|
||||
},
|
||||
// --- Member add (member_manage). Grants trip access — the target must exist;
|
||||
// the acting user is recorded as the inviter. Owner/duplicate adds are no-ops. ---
|
||||
canManageMembers: (tripId, userId) => canEditTripAs('member_manage', tripId, userId),
|
||||
addTripMember: (tripId, targetUserId, invitedBy) => {
|
||||
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId) as { id: number } | undefined;
|
||||
if (!target) throw new ForbiddenResource(`no user ${targetUserId}`);
|
||||
return joinTripAsMember(tripId, targetUserId, invitedBy);
|
||||
},
|
||||
removeTripMember: (tripId, targetUserId) => {
|
||||
// Never remove the OWNER via this path — that would orphan the trip. Ownership
|
||||
// transfer is a separate, deliberate action, not a member-management side effect.
|
||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
|
||||
if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
|
||||
removeTripMemberSvc(tripId, targetUserId);
|
||||
return { removed: true };
|
||||
},
|
||||
// --- Host-mediated notifications. Recipient resolution + channel fan-out +
|
||||
// per-user preferences are all owned by notificationService.send; the plugin
|
||||
// supplies only the target (host-scoped by the router) + plain text. actorId is
|
||||
// null (no user sender), so the message body carries the plugin's content. ---
|
||||
canAccessTripForNotify: (tripId, userId) => !!canAccessTrip(tripId, userId),
|
||||
sendPluginNotification: async (_pluginId, input) => {
|
||||
if (!budgetFor(id).take('notify', Date.now())) throw new BadParams('daily notification budget exhausted (resets at UTC midnight)');
|
||||
await sendNotification({
|
||||
event: 'plugin_notification',
|
||||
actorId: null,
|
||||
params: { title: input.title, body: input.body, ...(input.link ? { link: input.link } : {}) },
|
||||
scope: input.scope,
|
||||
targetId: input.targetId,
|
||||
inApp: input.link ? { navigateTarget: input.link } : undefined,
|
||||
});
|
||||
return { sent: true };
|
||||
},
|
||||
// --- Host-mediated LLM. The host holds the credential (resolveLlmConfig, encrypted
|
||||
// apiKey) and runs the call under the acting user's provider config; caps + the
|
||||
// provider-availability check live at the router. Reuses the extraction client:
|
||||
// complete() wraps it with a {text} schema; extract() passes the plugin's schema. ---
|
||||
aiConfigured: (userId) => resolveLlmConfig(userId) !== null,
|
||||
aiComplete: async (userId, prompt, system) => {
|
||||
const config = resolveLlmConfig(userId);
|
||||
if (!config) throw new BadParams('no AI provider is configured for this user');
|
||||
if (!budgetFor(id).take('ai', Date.now())) throw new BadParams('daily AI budget exhausted (resets at UTC midnight)');
|
||||
const results = await createLlmClient(config).extract({
|
||||
prompt: system || 'You are a helpful assistant. Reply with a JSON object of the form {"text": "..."} whose "text" field holds your answer.',
|
||||
jsonSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
||||
model: config.model, baseUrl: config.baseUrl, apiKey: config.apiKey, text: prompt,
|
||||
});
|
||||
const first = results[0] as { text?: unknown } | undefined;
|
||||
return { text: typeof first?.text === 'string' ? first.text : '' };
|
||||
},
|
||||
aiExtract: async (userId, text, jsonSchema, prompt) => {
|
||||
const config = resolveLlmConfig(userId);
|
||||
if (!config) throw new BadParams('no AI provider is configured for this user');
|
||||
if (!budgetFor(id).take('ai', Date.now())) throw new BadParams('daily AI budget exhausted (resets at UTC midnight)');
|
||||
const results = await createLlmClient(config).extract({
|
||||
prompt: prompt || 'Extract structured data from the text into the given JSON schema.',
|
||||
jsonSchema, model: config.model, baseUrl: config.baseUrl, apiKey: config.apiKey, text,
|
||||
});
|
||||
return { results };
|
||||
},
|
||||
// The acting user's own decrypted value for one of this plugin's user-scope settings.
|
||||
getUserSetting: (pluginId, userId, key) => readUserSettingDecrypted(pluginId, userId, key),
|
||||
// A short-lived OAuth access token for the acting user (host-brokered; refreshes).
|
||||
getOAuthToken: (pluginId, userId) => new PluginOAuthService().getAccessToken(pluginId, userId, Date.now()),
|
||||
// Persistent scheduler (jobs:run). Caps bound the abuse surface: a plugin can't
|
||||
// hoard timers, name-bomb, ship a huge payload, or busy-loop a recurring task.
|
||||
schedulerSet: (name, dueAt, everyMs, payload) => {
|
||||
const SCHED_MAX = 100; // entries per plugin
|
||||
const NAME_MAX = 128;
|
||||
const PAYLOAD_MAX = 8 * 1024; // 8 KB JSON
|
||||
const EVERY_MIN = 60_000; // 1 min floor for recurring
|
||||
const DUE_MAX = Date.now() + 366 * 24 * 60 * 60 * 1000; // <= ~1 year out
|
||||
if (!name || name.length > NAME_MAX) throw new BadParams(`scheduler name is required (max ${NAME_MAX} chars)`);
|
||||
if (!Number.isFinite(dueAt) || dueAt > DUE_MAX) throw new BadParams('scheduler dueAt out of range');
|
||||
if (everyMs !== undefined && (!Number.isFinite(everyMs) || everyMs < EVERY_MIN)) throw new BadParams(`recurring interval must be >= ${EVERY_MIN} ms`);
|
||||
const json = JSON.stringify(payload ?? null);
|
||||
if (json.length > PAYLOAD_MAX) throw new BadParams(`scheduler payload too large (max ${PAYLOAD_MAX} bytes)`);
|
||||
const existing = db.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(id, name) as { id: number } | undefined;
|
||||
if (!existing) {
|
||||
const n = (db.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(id) as { c: number }).c;
|
||||
if (n >= SCHED_MAX) throw new BadParams(`too many scheduled tasks (max ${SCHED_MAX})`);
|
||||
}
|
||||
// Upsert by (plugin, name): re-scheduling the same name replaces it.
|
||||
db.prepare(
|
||||
`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`,
|
||||
).run(id, name, Math.max(dueAt, Date.now()), json, everyMs ?? null);
|
||||
return { scheduled: true };
|
||||
},
|
||||
schedulerCancel: (name) => {
|
||||
const r = db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(id, name);
|
||||
return { cancelled: r.changes > 0 };
|
||||
},
|
||||
listCostsForTrip: (tripId) => listBudgetItems(tripId),
|
||||
// Cross-trip: every accessible trip's budget items (membership predicate is
|
||||
// baked into listTrips). Reuses the hydrated list so members/payers come too.
|
||||
@@ -196,6 +548,23 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Trip creation (trip_create; owner = acting user). No broadcast — a new trip
|
||||
// is only visible to its owner, who refetches (same as the REST POST). ---
|
||||
canCreateTrip: (userId) => {
|
||||
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
return checkPermission('trip_create', u?.role ?? 'user', null, userId, false);
|
||||
},
|
||||
createTripForUser: (userId, input) => {
|
||||
try {
|
||||
const result = createTrip(userId, input as unknown as Parameters<typeof createTrip>[1]);
|
||||
return result.trip;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw new BadParams(e.message);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
// --- Exchange rates: the same cached upstream feed the budget uses (tenant-free). ---
|
||||
getRates: (base) => getExchangeRates(base),
|
||||
// --- Trip (trip_edit). Only the schema-writable fields reach updateTrip; its
|
||||
// NotFound/Validation errors are mapped to clean RPC codes. ---
|
||||
canEditTrip: (tripId, userId) => canEditTripAs('trip_edit', tripId, userId),
|
||||
@@ -220,13 +589,308 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
// --- Cross-trip reads. The membership predicate is baked into listTrips, so a
|
||||
// plugin only ever sees the acting user's own trips/reservations (no raw
|
||||
// cross-tenant SELECT). Reuses the same hydrated services as the REST paths. ---
|
||||
listTripsForUser: (userId) => listTrips(userId, null),
|
||||
listReservationsForUser: (userId) => {
|
||||
const trips = listTrips(userId, null) as Array<{ id: number }>;
|
||||
return trips.flatMap((t) => reservationsSvc.list(String(t.id)));
|
||||
},
|
||||
// --- Trip-scoped hydrated reads (membership already checked by tripRead). Same
|
||||
// services as the REST GETs, so plugins see the exact planner shapes. ---
|
||||
listTripDays: (tripId) => (listDays(tripId) as { days: unknown[] }).days,
|
||||
listTripReservations: (tripId) => reservationsSvc.list(String(tripId)),
|
||||
listTripAccommodations: (tripId) => listAccommodations(tripId) as unknown[],
|
||||
// --- Accommodations (lodging blocks, day_edit). Delegates to dayService so the
|
||||
// partner hotel reservation, the metadata sync and the delete cascade behave
|
||||
// exactly like the accommodations REST controller, cascade broadcasts included. ---
|
||||
createAccommodation: (tripId, input) => {
|
||||
const i = input as { place_id: number | string; start_day_id: number | string; end_day_id: number | string; check_in?: string | null; check_in_end?: string | null; check_out?: string | null; confirmation?: string | null; notes?: string | null };
|
||||
const placeId = Math.trunc(Number(i.place_id));
|
||||
const startDayId = Math.trunc(Number(i.start_day_id));
|
||||
const endDayId = Math.trunc(Number(i.end_day_id));
|
||||
if (!placeId || !startDayId || !endDayId) throw new BadParams('place_id, start_day_id, and end_day_id are required');
|
||||
const errors = validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
|
||||
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
|
||||
const accommodation = createAccommodationSvc(tripId, {
|
||||
place_id: placeId, start_day_id: startDayId, end_day_id: endDayId,
|
||||
check_in: i.check_in ?? undefined, check_in_end: i.check_in_end ?? undefined,
|
||||
check_out: i.check_out ?? undefined, confirmation: i.confirmation ?? undefined, notes: i.notes ?? undefined,
|
||||
});
|
||||
broadcast(tripId, 'accommodation:created', { accommodation });
|
||||
broadcast(tripId, 'reservation:created', {});
|
||||
return accommodation;
|
||||
},
|
||||
updateAccommodation: (tripId, accommodationId, input) => {
|
||||
const existing = getAccommodation(accommodationId, tripId);
|
||||
if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
|
||||
const i = input as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string };
|
||||
const errors = validateAccommodationRefs(tripId, i.place_id, i.start_day_id, i.end_day_id);
|
||||
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
|
||||
const accommodation = updateAccommodationSvc(accommodationId, existing, i);
|
||||
broadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
return accommodation;
|
||||
},
|
||||
deleteAccommodation: (tripId, accommodationId) => {
|
||||
if (!getAccommodation(accommodationId, tripId)) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
|
||||
const { linkedReservationId, deletedBudgetItemId } = deleteAccommodationSvc(accommodationId);
|
||||
if (linkedReservationId) broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId });
|
||||
if (deletedBudgetItemId) broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId });
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- User-scoped addon reads (the acting user's own data across all trips). Each
|
||||
// reuses the same service the addon's REST/MCP path uses; the addon-enabled gate
|
||||
// mirrors the app (a disabled addon has nothing to read). ---
|
||||
listJournalsForUser: (userId) => { requireAddon(ADDON_IDS.JOURNEY, 'journey'); return listJourneys(userId); },
|
||||
journalEntriesForUser: (userId, journeyId) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
// listEntries self-gates via canAccessJourney(journeyId, userId) → null if the
|
||||
// user can't see it (owner/contributor only).
|
||||
const entries = listJournalEntriesSvc(journeyId, userId);
|
||||
if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`);
|
||||
return entries;
|
||||
},
|
||||
atlasVisitedForUser: (userId) => {
|
||||
requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
return { countries: listVisitedCountries(userId), regions: listManuallyVisitedRegions(userId) };
|
||||
},
|
||||
atlasBucketForUser: (userId) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return listBucketList(userId) as unknown[]; },
|
||||
vacayForUser: (userId) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return getPlanData(userId); },
|
||||
listCollectionsForUser: (userId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return listCollections(userId); },
|
||||
getCollectionForUser: (userId, id) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return getCollection(userId, id); },
|
||||
// --- Collections write. The service self-gates per-collection role (assertAccess/
|
||||
// assertCanEdit throw status-tagged errors) — map those to the RPC error codes. ---
|
||||
createCollectionForUser: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => createCollection(userId, input as never));
|
||||
},
|
||||
updateCollectionForUser: (userId, id, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => updateCollection(userId, id, input as never, undefined));
|
||||
},
|
||||
saveCollectionPlace: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => saveCollectionPlaceSvc(userId, input as never, undefined));
|
||||
},
|
||||
copyCollectionToTrip: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
return mapCollectionError(() => copyCollectionToTripSvc(userId, input as never));
|
||||
},
|
||||
deleteCollectionPlace: (userId, placeId) => {
|
||||
requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
mapCollectionError(() => deleteCollectionPlaceSvc(userId, placeId, undefined));
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Atlas write: plain uid-scoped rows, no broadcasts in the service. ---
|
||||
markCountryVisited: (userId, code) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); markCountryVisited(userId, code); return { visited: true }; },
|
||||
unmarkCountryVisited: (userId, code) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); unmarkCountryVisited(userId, code); return { visited: false }; },
|
||||
markRegionVisited: (userId, regionCode, regionName, countryCode) => {
|
||||
requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
markRegionVisited(userId, regionCode, regionName, countryCode);
|
||||
return { visited: true };
|
||||
},
|
||||
unmarkRegionVisited: (userId, regionCode) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); unmarkRegionVisited(userId, regionCode); return { visited: false }; },
|
||||
createBucketItem: (userId, input) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return createBucketItemSvc(userId, input as never); },
|
||||
deleteBucketItem: (userId, itemId) => {
|
||||
requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
if (!deleteBucketItemSvc(userId, itemId)) throw new ForbiddenResource(`no bucket item ${itemId} for this user`);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side);
|
||||
// the service broadcasts to plan users itself. ---
|
||||
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, undefined); },
|
||||
vacayToggleCompanyHoliday: (userId, date, note) => {
|
||||
requireAddon(ADDON_IDS.VACAY, 'vacay');
|
||||
return vacayToggleCompanyHolidaySvc(getActivePlanId(userId), date, note, undefined);
|
||||
},
|
||||
// --- Journal write: journeyService.canEdit self-gates each call (owner/contributor). ---
|
||||
createJournalEntry: (userId, journeyId, input) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
const entry = createJournalEntrySvc(journeyId, userId, input as never);
|
||||
if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`);
|
||||
return entry;
|
||||
},
|
||||
updateJournalEntry: (userId, entryId, input) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
const entry = updateJournalEntrySvc(entryId, userId, input as never);
|
||||
if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
|
||||
return entry;
|
||||
},
|
||||
deleteJournalEntry: (userId, entryId) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
if (!deleteJournalEntrySvc(entryId, userId)) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
|
||||
return { deleted: true };
|
||||
},
|
||||
createJournal: (userId, input) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
const title = typeof (input as { title?: unknown }).title === 'string' ? String((input as { title: string }).title).trim() : '';
|
||||
if (!title) throw new BadParams('journal title is required');
|
||||
return createJourneySvc(userId, { title, subtitle: (input as { subtitle?: string }).subtitle, trip_ids: (input as { trip_ids?: number[] }).trip_ids });
|
||||
},
|
||||
deleteJournal: (userId, journeyId) => {
|
||||
requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
if (!deleteJourneySvc(journeyId, userId)) throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
|
||||
return { deleted: true };
|
||||
},
|
||||
// Day notes are core (no addon) and trip-scoped; membership is enforced by the host.
|
||||
listDayNotes: (tripId, dayId) => listNotes(dayId, tripId),
|
||||
// --- Day notes write (day_edit). The day must belong to the trip; broadcasts the
|
||||
// same dayNote:* events the REST controller emits so open sessions update live. ---
|
||||
createDayNote: (tripId, dayId, input) => {
|
||||
if (!dayNoteDayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
const i = input as { text?: string; time?: string; icon?: string; sort_order?: number };
|
||||
const note = createNote(dayId, tripId, i.text ?? '', i.time, i.icon, i.sort_order);
|
||||
broadcast(tripId, 'dayNote:created', { dayId, note }, undefined);
|
||||
return note;
|
||||
},
|
||||
updateDayNote: (tripId, dayId, noteId, input) => {
|
||||
const current = getNote(noteId, dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`);
|
||||
const note = updateNote(noteId, current as never, input as { text?: string; time?: string; icon?: string; sort_order?: number });
|
||||
broadcast(tripId, 'dayNote:updated', { dayId, note }, undefined);
|
||||
return note;
|
||||
},
|
||||
deleteDayNote: (tripId, dayId, noteId) => {
|
||||
const current = getNote(noteId, dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`);
|
||||
deleteNote(noteId);
|
||||
broadcast(tripId, 'dayNote:deleted', { noteId, dayId }, undefined);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Reservations (bookings, reservation_edit). Delegates to ReservationsService
|
||||
// so the accommodation/budget-sync/notification/broadcast side effects match the
|
||||
// web app EXACTLY. socketId is undefined — a plugin has no originating socket. ---
|
||||
canEditReservations: (tripId, userId) => canEditTripAs('reservation_edit', tripId, userId),
|
||||
createReservation: (tripId, input, actingUserId) => {
|
||||
const { reservation, accommodationCreated } = reservationsSvc.create(String(tripId), input as never);
|
||||
if (accommodationCreated) broadcast(tripId, 'accommodation:created', {}, undefined);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
reservationsSvc.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined);
|
||||
broadcast(tripId, 'reservation:created', { reservation }, undefined);
|
||||
notifyBooking(actingUserId, tripId, i.title ?? '', i.type ?? '');
|
||||
return reservation;
|
||||
},
|
||||
updateReservation: (tripId, reservationId, input, actingUserId) => {
|
||||
const current = reservationsSvc.getReservation(String(reservationId), String(tripId));
|
||||
if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`);
|
||||
const { reservation, accommodationChanged } = reservationsSvc.update(String(reservationId), String(tripId), input as never, current as never);
|
||||
if (accommodationChanged) broadcast(tripId, 'accommodation:updated', {}, undefined);
|
||||
const cur = current as { title: string; type?: string };
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
reservationsSvc.syncBudgetOnUpdate(String(tripId), String(reservationId), i.title ?? '', i.type, cur.title, cur.type, i.create_budget_entry as never, undefined);
|
||||
broadcast(tripId, 'reservation:updated', { reservation }, undefined);
|
||||
notifyBooking(actingUserId, tripId, i.title || cur.title, i.type || cur.type || '');
|
||||
return reservation;
|
||||
},
|
||||
deleteReservation: (tripId, reservationId, actingUserId) => {
|
||||
const { deleted, accommodationDeleted, deletedBudgetItemId } = reservationsSvc.remove(String(reservationId), String(tripId));
|
||||
if (!deleted) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`);
|
||||
if (accommodationDeleted) broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }, undefined);
|
||||
if (deletedBudgetItemId) broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, undefined);
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(reservationId) }, undefined);
|
||||
notifyBooking(actingUserId, tripId, deleted.title, deleted.type || '');
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Packing (packing_edit). Reuses packingService + replicates the #858
|
||||
// privacy-scoped broadcasts (create/delete via emitPackingToViewers, update via
|
||||
// the four-case broadcastPackingUpdate) so a private item never leaks room-wide. ---
|
||||
canEditPacking: (tripId, userId) => canEditTripAs('packing_edit', tripId, userId),
|
||||
createPackingItem: (tripId, input, actingUserId) => {
|
||||
const i = input as { name: string; category?: string; checked?: boolean; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] };
|
||||
const item = createPackingItemSvc(String(tripId), i, actingUserId) as PackingPrivacy;
|
||||
emitPackingToViewers(tripId, 'packing:created', { item }, item);
|
||||
return item;
|
||||
},
|
||||
updatePackingItem: (tripId, itemId, input, actingUserId) => {
|
||||
// Privacy BEFORE the write, so a public<->private toggle routes correctly.
|
||||
const before = packingItemPrivacy(tripId, itemId);
|
||||
const updated = updatePackingItemSvc(String(tripId), String(itemId), input as never, Object.keys(input), undefined, actingUserId);
|
||||
if (!updated) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
|
||||
if (isUpdateConflict(updated)) throw new BadParams('packing item was modified concurrently');
|
||||
broadcastPackingUpdate(tripId, itemId, updated as PackingPrivacy, !!before?.is_private);
|
||||
return updated;
|
||||
},
|
||||
deletePackingItem: (tripId, itemId) => {
|
||||
const deleted = deletePackingItemSvc(String(tripId), String(itemId)) as PackingPrivacy | null;
|
||||
if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
|
||||
emitPackingToViewers(tripId, 'packing:deleted', { itemId }, deleted);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Packing bags (no privacy — plain room broadcasts). ---
|
||||
listPackingBags: (tripId) => listBags(String(tripId)) as unknown[],
|
||||
createPackingBag: (tripId, input) => {
|
||||
const i = input as { name: string; color?: string };
|
||||
const bag = createBagSvc(String(tripId), { name: i.name, color: i.color });
|
||||
broadcast(tripId, 'packing:bag-created', { bag }, undefined);
|
||||
return bag;
|
||||
},
|
||||
updatePackingBag: (tripId, bagId, input) => {
|
||||
const bag = updateBagSvc(String(tripId), String(bagId), input as never, Object.keys(input));
|
||||
if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'packing:bag-updated', { bag }, undefined);
|
||||
return bag;
|
||||
},
|
||||
deletePackingBag: (tripId, bagId) => {
|
||||
if (!deleteBagSvc(String(tripId), String(bagId))) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'packing:bag-deleted', { bagId }, undefined);
|
||||
return { deleted: true };
|
||||
},
|
||||
setPackingBagMembers: (tripId, bagId, userIds) => {
|
||||
const members = setBagMembers(String(tripId), String(bagId), userIds);
|
||||
if (!members) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'packing:bag-members-updated', { bagId, members }, undefined);
|
||||
return members;
|
||||
},
|
||||
// --- Read-convenience: weather (host cache, tenant-free), categories (global), roster ---
|
||||
getWeather: (lat, lng, date) => getWeather(String(lat), String(lng), date, 'en'),
|
||||
listCategories: () => listCategories() as unknown[],
|
||||
tripMembers: (tripId) =>
|
||||
db.prepare('SELECT u.id, u.username, u.display_name, u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE tm.trip_id = ?').all(tripId) as unknown[],
|
||||
// --- Tags (the acting user's own; ownership re-checked before a write). ---
|
||||
listTagsForUser: (userId) => listTags(userId) as unknown[],
|
||||
createTagForUser: (userId, name, color) => createTag(userId, name, color),
|
||||
updateTagForUser: (userId, tagId, name, color) => {
|
||||
if (!getTagByIdAndUser(tagId, userId)) throw new ForbiddenResource(`no tag ${tagId} for this user`);
|
||||
return updateTag(tagId, name, color);
|
||||
},
|
||||
deleteTagForUser: (userId, tagId) => {
|
||||
if (!getTagByIdAndUser(tagId, userId)) throw new ForbiddenResource(`no tag ${tagId} for this user`);
|
||||
deleteTag(tagId);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Todos (core, trip-scoped; the app's 'packing_edit' permission). ---
|
||||
canEditTodos: (tripId, userId) => canEditTripAs('packing_edit', tripId, userId),
|
||||
listTodos: (tripId) => listTodosSvc(String(tripId)) as unknown[],
|
||||
createTodo: (tripId, input) => {
|
||||
const item = createTodoSvc(String(tripId), input as never);
|
||||
broadcast(tripId, 'todo:created', { item }, undefined);
|
||||
return item;
|
||||
},
|
||||
updateTodo: (tripId, todoId, input) => {
|
||||
const updated = updateTodoSvc(String(tripId), String(todoId), input as never, Object.keys(input));
|
||||
if (!updated) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'todo:updated', { item: updated }, undefined);
|
||||
return updated;
|
||||
},
|
||||
deleteTodo: (tripId, todoId) => {
|
||||
if (!deleteTodoSvc(String(tripId), String(todoId))) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'todo:deleted', { itemId: todoId }, undefined);
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Plugin metadata (db:meta). A per-plugin namespaced key/value store keyed
|
||||
// to a core entity; the plugin only ever sees rows tagged with its own id. ---
|
||||
metaEntityTrip: (entityType, entityId) => {
|
||||
if (entityType === 'trip') {
|
||||
return (db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id;
|
||||
}
|
||||
const table = entityType === 'place' ? 'places' : 'days';
|
||||
// Each of these tables has a NOT NULL trip_id, so the metadata gate resolves to
|
||||
// the owning trip and reuses the standard canAccessTrip / *_edit checks.
|
||||
const table = entityType === 'place' ? 'places'
|
||||
: entityType === 'day' ? 'days'
|
||||
: entityType === 'reservation' ? 'reservations'
|
||||
: 'day_accommodations'; // accommodation
|
||||
return (db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id;
|
||||
},
|
||||
metaGet: (entityType, entityId, key) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Per-plugin daily budgets for the host-mediated brokers (#plugins, security).
|
||||
*
|
||||
* The RPC rate limit (rate-limit.ts) bounds how FAST a plugin calls the host; this
|
||||
* bounds how MUCH it spends per day on the two brokers that cost real money or
|
||||
* annoyance: the shared LLM provider (`ai.complete`/`ai.extract`) and user
|
||||
* notifications (`notify.send`). Without it, one plugin could burn the admin's LLM
|
||||
* quota or spam a user, entirely within its granted permissions.
|
||||
*
|
||||
* Zero-config: generous defaults apply automatically (env-overridable, never
|
||||
* required). Counts live in memory, seeded on first use from the local capability
|
||||
* audit (which already records every ai/notify call) — so a restart doesn't reset
|
||||
* the day, and nothing phones home. A UTC-midnight rollover resets the window.
|
||||
*/
|
||||
|
||||
export interface DailyBudgetConfig {
|
||||
aiPerDay: number;
|
||||
notifyPerDay: number;
|
||||
}
|
||||
|
||||
/** Read a non-negative integer env override, falling back to `def` only when the
|
||||
* value is absent or malformed. A literal 0 is honoured (disable the broker), which a
|
||||
* `Number(x) || def` would have silently turned back into the default. */
|
||||
export function envCap(name: string, def: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === '') return def;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : def;
|
||||
}
|
||||
|
||||
// Generous by design — these only bite an abusive/runaway plugin, never a normal
|
||||
// one. Overridable via env for a self-hoster who wants tighter or looser caps
|
||||
// (set to 0 to disable a broker entirely).
|
||||
export const DEFAULT_DAILY_BUDGET: DailyBudgetConfig = {
|
||||
aiPerDay: envCap('TREK_PLUGIN_AI_PER_DAY', 200),
|
||||
notifyPerDay: envCap('TREK_PLUGIN_NOTIFY_PER_DAY', 100),
|
||||
};
|
||||
|
||||
export type BudgetKind = 'ai' | 'notify';
|
||||
|
||||
/** The UTC calendar day of a timestamp, as a comparable 'YYYY-MM-DD' string. */
|
||||
function utcDay(now: number): string {
|
||||
return new Date(now).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-plugin daily counter for the two metered brokers. `take(kind)` returns
|
||||
* false once the day's budget for that kind is spent; the window rolls over at
|
||||
* UTC midnight. Seed with today's already-spent counts so a restart continues the
|
||||
* same day instead of handing the plugin a fresh budget.
|
||||
*/
|
||||
export class DailyBudget {
|
||||
private day: string;
|
||||
private ai = 0;
|
||||
private notify = 0;
|
||||
|
||||
constructor(private readonly cfg: DailyBudgetConfig, now: number, seed?: { ai?: number; notify?: number }) {
|
||||
this.day = utcDay(now);
|
||||
this.ai = seed?.ai ?? 0;
|
||||
this.notify = seed?.notify ?? 0;
|
||||
}
|
||||
|
||||
private rollover(now: number): void {
|
||||
const today = utcDay(now);
|
||||
if (today !== this.day) {
|
||||
this.day = today;
|
||||
this.ai = 0;
|
||||
this.notify = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve one unit of `kind`'s daily budget. False = exhausted for today. */
|
||||
take(kind: BudgetKind, now: number): boolean {
|
||||
this.rollover(now);
|
||||
if (kind === 'ai') {
|
||||
if (this.ai >= this.cfg.aiPerDay) return false;
|
||||
this.ai += 1;
|
||||
return true;
|
||||
}
|
||||
if (this.notify >= this.cfg.notifyPerDay) return false;
|
||||
this.notify += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Current usage snapshot for the admin view (read-only). */
|
||||
used(now: number): { day: string; ai: number; aiMax: number; notify: number; notifyMax: number } {
|
||||
this.rollover(now);
|
||||
return { day: this.day, ai: this.ai, aiMax: this.cfg.aiPerDay, notify: this.notify, notifyMax: this.cfg.notifyPerDay };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { METHOD_PERMISSION } from '../protocol/envelope';
|
||||
|
||||
/**
|
||||
* Host-side, hash-chained capability audit (#plugins, L1 hardening).
|
||||
@@ -29,36 +30,80 @@ export interface AuditEntry {
|
||||
|
||||
/** The methods worth auditing: core-data reads + broadcasts, not own-db noise. */
|
||||
export function auditResource(method: string, params: Record<string, unknown>): string | null {
|
||||
if (method.startsWith('trips.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method === 'trips.create') return 'trips:new';
|
||||
if (method === 'trips.listMine') return 'trips:all';
|
||||
if (method === 'reservations.listMine') return 'reservations:all';
|
||||
if (method.startsWith('trips.') || method.startsWith('reservations.') || method.startsWith('accommodations.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method === 'costs.listMine') return 'costs:all';
|
||||
if (method.startsWith('costs.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method.startsWith('places.') || method.startsWith('days.') || method.startsWith('itinerary.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method.startsWith('packing.') || method.startsWith('files.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method === 'journal.listMine') return 'journal:all';
|
||||
if (method.startsWith('journal.')) return `journal:entry:${params.entryId ?? params.journeyId ?? '?'}`;
|
||||
if (method === 'atlas.visited') return 'atlas:all';
|
||||
if (method.startsWith('atlas.')) return 'atlas:own';
|
||||
if (method === 'vacay.mine') return 'vacay:all';
|
||||
if (method.startsWith('vacay.')) return 'vacay:own';
|
||||
if (method === 'collections.listMine') return 'collections:all';
|
||||
if (method.startsWith('collections.')) return `collection:${params.id ?? params.placeId ?? '?'}`;
|
||||
if (method.startsWith('daynotes.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method.startsWith('collab.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method.startsWith('todos.')) return `trip:${params.tripId ?? '?'}`;
|
||||
if (method === 'weather.get') return 'weather:global';
|
||||
if (method === 'rates.get') return 'rates:global';
|
||||
if (method === 'categories.list') return 'categories:all';
|
||||
if (method.startsWith('tags.')) return 'tags:own';
|
||||
if (method.startsWith('meta.')) return `${params.entityType ?? '?'}:${params.entityId ?? '?'}`;
|
||||
if (method === 'users.getById') return `user:${params.id ?? '?'}`;
|
||||
if (method === 'ws.broadcastToTrip') return `trip:${params.tripId ?? '?'}`;
|
||||
if (method === 'ws.broadcastToUser') return `user:${params.userId ?? '?'}`;
|
||||
if (method === 'notify.send') { const i = (params.input ?? {}) as Record<string, unknown>; return `notify:${i.scope ?? '?'}:${i.targetId ?? '?'}`; }
|
||||
if (method === 'ai.complete' || method === 'ai.extract') return 'ai:invoke';
|
||||
if (method === 'oauth.getToken') return 'oauth:token';
|
||||
if (method.startsWith('scheduler.')) return `scheduler:${params.name ?? '?'}`;
|
||||
if (method === 'plugins.call') return `plugin:${params.targetId ?? '?'}#${params.fn ?? '?'}`;
|
||||
if (method === 'events.emit') return `event:${params.event ?? '?'}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True for calls we record (core data + ws); a plugin's own-db calls are skipped. */
|
||||
/** True for calls we record (core data + ws); a plugin's own-db calls are skipped.
|
||||
*
|
||||
* Derived from METHOD_PERMISSION so a NEW capability method is auto-audited: anything
|
||||
* that unlocks via a permission other than `db:own` is core surface worth recording.
|
||||
* `plugins.call`/`events.emit` carry no permission (registered unconditionally) but are
|
||||
* core surface too, so they are named explicitly. This makes it impossible to add a
|
||||
* capability that reaches core data without an audit entry by omission. */
|
||||
export function isAuditable(method: string): boolean {
|
||||
return (
|
||||
method.startsWith('trips.') ||
|
||||
method.startsWith('costs.') ||
|
||||
method.startsWith('places.') ||
|
||||
method.startsWith('days.') ||
|
||||
method.startsWith('itinerary.') ||
|
||||
method.startsWith('packing.') ||
|
||||
method.startsWith('files.') ||
|
||||
method.startsWith('meta.') ||
|
||||
method.startsWith('users.') ||
|
||||
method.startsWith('ws.') ||
|
||||
method.startsWith('plugins.') ||
|
||||
method.startsWith('events.')
|
||||
);
|
||||
if (method === 'plugins.call' || method === 'events.emit') return true;
|
||||
const perm = (METHOD_PERMISSION as Record<string, string | undefined>)[method];
|
||||
return perm !== undefined && perm !== 'db:own';
|
||||
}
|
||||
|
||||
// Per-plugin retention cap for the audit table — it lives in the SHARED trek.db, and a
|
||||
// busy granted plugin at the sustained RPC rate could otherwise add a million rows a day
|
||||
// with no reclaim path. Default 20k rows/plugin (weeks of normal activity), env-tunable;
|
||||
// 0 disables pruning. Pruning is chain-SAFE: each retained row's hash still equals
|
||||
// sha256(its stored prev_hash + its content), so the retained window stays tamper-evident
|
||||
// — only continuity to a now-deleted genesis is lost, which is inherent to any retention.
|
||||
const MAX_AUDIT_ROWS = envInt('TREK_PLUGIN_AUDIT_MAX_ROWS', 20_000);
|
||||
const PRUNE_EVERY = 500; // amortise the COUNT/DELETE over this many appends per plugin
|
||||
const appendsSincePrune = new Map<string, number>();
|
||||
|
||||
function envInt(name: string, def: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === '') return def;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : def;
|
||||
}
|
||||
|
||||
/** Keep only the newest `MAX_AUDIT_ROWS` rows for a plugin. Called amortised from
|
||||
* appendAudit; exported for tests. No-op when disabled or under the cap. */
|
||||
export function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): void {
|
||||
if (keep <= 0) return;
|
||||
db.prepare(
|
||||
`DELETE FROM plugin_capability_audit WHERE plugin_id = ? AND id NOT IN
|
||||
(SELECT id FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?)`,
|
||||
).run(pluginId, pluginId, keep);
|
||||
}
|
||||
|
||||
/** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */
|
||||
@@ -73,6 +118,24 @@ export function appendAudit(db: AuditDb, e: AuditEntry): void {
|
||||
db.prepare(
|
||||
'INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)',
|
||||
).run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prev || null, hash);
|
||||
// Amortised retention: prune roughly every PRUNE_EVERY appends per plugin.
|
||||
const n = (appendsSincePrune.get(e.pluginId) ?? 0) + 1;
|
||||
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); pruneAudit(db, e.pluginId); }
|
||||
else appendsSincePrune.set(e.pluginId, n);
|
||||
}
|
||||
|
||||
/** Read the most recent audit rows across ALL plugins for one acting user — the
|
||||
* "what have plugins done in my name?" view. This is what legitimizes the broad
|
||||
* read grants: the user, not just the admin, can see every plugin action bound to
|
||||
* them. Joined with the plugin name for display; capped. */
|
||||
export function readAuditForUser(db: AuditDb, userId: number, limit = 200): unknown[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT a.ts, a.plugin_id, p.name AS plugin_name, a.method, a.resource, a.code
|
||||
FROM plugin_capability_audit a LEFT JOIN plugins p ON p.id = a.plugin_id
|
||||
WHERE a.acting_user_id = ? ORDER BY a.id DESC LIMIT ?`,
|
||||
)
|
||||
.all(userId, limit);
|
||||
}
|
||||
|
||||
/** Read the most recent audit rows for a plugin (admin view). */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { Database as Db } from 'better-sqlite3';
|
||||
import { pluginDataDir, pluginDbFile } from '../paths';
|
||||
import { pluginDataDir, pluginDbFile, pluginsDataRoot } from '../paths';
|
||||
|
||||
/**
|
||||
* A plugin's own sqlite database (#plugins, db:own). The HOST owns the handle;
|
||||
@@ -21,7 +22,14 @@ const MAX_SQL_LENGTH = 100_000;
|
||||
// forever even with an empty database, which neither the size quota nor the
|
||||
// result-row cap can stop (an aggregate over it never yields a first row). Refuse
|
||||
// it outright; the row/size caps below bound everything else.
|
||||
const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA|RECURSIVE)\b/i;
|
||||
// load_extension is included as defense-in-depth: better-sqlite3 disables
|
||||
// extension loading by default (so it's inert today), but banning it in the guard
|
||||
// means a future connection-option slip can't turn it into an arbitrary-.so RCE.
|
||||
const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA|RECURSIVE|LOAD_EXTENSION)\b/i;
|
||||
// Transaction-control keywords, matched only at statement start (so CASE…END and
|
||||
// identifiers are unaffected). Refused inside db.tx() so a plugin can't COMMIT the
|
||||
// batch's earlier writes and then have the wrapper report failure — breaking atomicity.
|
||||
const TX_CONTROL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|END)\b/i;
|
||||
// Per-plugin on-disk quota. better-sqlite3 is synchronous and runs in the HOST
|
||||
// process, so an unbounded plugin DB is both a disk-exhaustion DoS on the shared
|
||||
// trek.db volume and (via a huge scan) an event-loop stall. max_page_count caps
|
||||
@@ -30,15 +38,35 @@ const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA|RECURSIVE)\b/i;
|
||||
// recursive CTE / cartesian product can't materialize an unbounded array.
|
||||
const QUOTA_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_ROWS = 100_000;
|
||||
// Cap statements per atomic batch so a single tx() can't monopolise the synchronous
|
||||
// host — generous for real write batches, far below anything abusive.
|
||||
const MAX_TX_OPS = 100;
|
||||
|
||||
// Every live per-plugin handle, so a backup can WAL-checkpoint them before archiving
|
||||
// (the host keeps these open, so their .db files would otherwise be copied with recent
|
||||
// commits still stranded in the -wal sidecar → a stale/torn snapshot in the backup).
|
||||
const openDbs = new Set<PluginDataDb>();
|
||||
|
||||
/** Fold the WAL back into each open plugin.db so a subsequent file copy is a complete,
|
||||
* consistent snapshot — mirrors the wal_checkpoint the core backup runs on travel.db.
|
||||
* Best-effort per handle; never throws. */
|
||||
export function checkpointAllPluginDataDbs(): void {
|
||||
for (const d of openDbs) {
|
||||
try { d.checkpoint(); } catch { /* a busy/closed handle is skipped, not fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginDataDb {
|
||||
private db: Db;
|
||||
readonly pluginId: string;
|
||||
|
||||
constructor(pluginId: string) {
|
||||
this.pluginId = pluginId;
|
||||
fs.mkdirSync(pluginDataDir(pluginId), { recursive: true });
|
||||
this.db = new Database(pluginDbFile(pluginId));
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
openDbs.add(this);
|
||||
// Cap the file size (per-connection; not persisted, so set on every open).
|
||||
const pageSize = Number(this.db.pragma('page_size', { simple: true })) || 4096;
|
||||
this.db.pragma(`max_page_count = ${Math.max(1, Math.floor(QUOTA_BYTES / pageSize))}`);
|
||||
@@ -78,6 +106,49 @@ export class PluginDataDb {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic batch on the plugin's OWN db: every op runs in a single transaction, so
|
||||
* they all commit or all roll back — the primitive a plugin needs for a consistent
|
||||
* multi-write (e.g. move an item between two tables). Each op is ONE statement;
|
||||
* a read (SELECT/RETURNING) yields `{ rows }`, a write yields `{ changes }`, and
|
||||
* reads within the batch see the batch's own earlier writes (read-modify-write).
|
||||
*/
|
||||
tx(ops: Array<{ sql: string; args?: unknown[] }>): { results: Array<{ changes?: number; rows?: unknown[] }> } {
|
||||
if (!Array.isArray(ops)) throw new Error('tx requires an array of { sql, args }');
|
||||
if (ops.length === 0) return { results: [] };
|
||||
if (ops.length > MAX_TX_OPS) throw new Error(`tx allows at most ${MAX_TX_OPS} statements`);
|
||||
for (const op of ops) {
|
||||
this.guard(op?.sql);
|
||||
// Reject transaction-control statements: a raw COMMIT/ROLLBACK inside the batch
|
||||
// would break atomicity — it commits the earlier writes even though the wrapper
|
||||
// then reports the tx as failed. Strip any LEADING comments/whitespace first so a
|
||||
// `/* */COMMIT` or `-- x\nCOMMIT` can't slip past the start-anchored check; these
|
||||
// keywords are only valid at statement start, so CASE ... END is unaffected.
|
||||
const head = String(op?.sql ?? '').replace(/^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/, '');
|
||||
if (TX_CONTROL.test(head)) throw new Error('transaction-control statements are not allowed inside tx()');
|
||||
}
|
||||
let batchRows = 0; // one row budget for the WHOLE batch, not per statement
|
||||
const run = this.db.transaction((batch: Array<{ sql: string; args?: unknown[] }>) => {
|
||||
const results: Array<{ changes?: number; rows?: unknown[] }> = [];
|
||||
for (const op of batch) {
|
||||
const stmt = this.db.prepare(op.sql);
|
||||
const args = (op.args ?? []) as never[];
|
||||
if (stmt.reader) {
|
||||
const rows: unknown[] = [];
|
||||
for (const row of stmt.iterate(...args)) {
|
||||
rows.push(row);
|
||||
if (++batchRows > MAX_ROWS) throw new Error(`tx returned more than ${MAX_ROWS} rows in total`);
|
||||
}
|
||||
results.push({ rows });
|
||||
} else {
|
||||
results.push({ changes: stmt.run(...args).changes });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
return { results: run(ops) };
|
||||
}
|
||||
|
||||
/** Run a migration once, keyed by id. Re-running with the same id is a no-op. */
|
||||
migrate(id: string, sql: string): { applied: boolean } {
|
||||
this.guard(sql);
|
||||
@@ -90,8 +161,32 @@ export class PluginDataDb {
|
||||
return { applied: true };
|
||||
}
|
||||
|
||||
/** Whether the underlying sqlite handle is still open (better-sqlite3 `.open`).
|
||||
* The host uses this to detect a handle closed by a terminal-failure dispose that
|
||||
* left the instance cached, so it can recreate it instead of throwing on reuse. */
|
||||
isOpen(): boolean {
|
||||
return this.db.open;
|
||||
}
|
||||
|
||||
/** Fold the WAL back into the main db file (checkpoint TRUNCATE) so a file-level copy
|
||||
* is a complete snapshot. No-op on a closed handle. */
|
||||
checkpoint(): void {
|
||||
if (this.db.open) this.db.pragma('wal_checkpoint(TRUNCATE)');
|
||||
}
|
||||
|
||||
/** Write a fully-consistent copy of this DB to `destPath` via VACUUM INTO. Unlike a
|
||||
* file copy it folds in the WAL and reads a point-in-time snapshot, so the result is
|
||||
* correct even while the plugin is writing — no torn page, no separate -wal to keep in
|
||||
* sync. This is a host op on the host's own handle, not plugin SQL, so it bypasses the
|
||||
* FORBIDDEN guard by design. */
|
||||
snapshotInto(destPath: string): void {
|
||||
fs.rmSync(destPath, { force: true }); // VACUUM INTO fails if the target already exists
|
||||
this.db.exec(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
openDbs.delete(this);
|
||||
this.db.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
@@ -103,3 +198,55 @@ export class PluginDataDb {
|
||||
export function removePluginData(pluginId: string): void {
|
||||
fs.rmSync(pluginDataDir(pluginId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy every plugin's data dir into `destRoot` as a CONSISTENT snapshot, for a backup to
|
||||
* archive instead of the live tree. An open plugin.db is captured with VACUUM INTO (safe
|
||||
* under concurrent writes); a plugin with no live handle is copied as-is (no writer). The
|
||||
* -wal/-shm sidecars are never copied — the snapshot folds them in, and copying them out
|
||||
* of step with the .db is exactly what produced torn/corrupt restores when the archiver
|
||||
* read the live files lazily while a plugin kept writing. Blobs and any other files a
|
||||
* plugin wrote to its dir are copied verbatim. Best-effort per file; never throws.
|
||||
*/
|
||||
export function snapshotAllPluginDataDbs(destRoot: string): void {
|
||||
const root = pluginsDataRoot();
|
||||
if (!fs.existsSync(root)) return;
|
||||
const openById = new Map<string, PluginDataDb>();
|
||||
for (const d of openDbs) if (d.isOpen()) openById.set(d.pluginId, d);
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const srcDir = path.join(root, entry.name);
|
||||
const destDir = path.join(destRoot, entry.name);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const open = openById.get(entry.name);
|
||||
// Handle the live db up front so we know whether its WAL got folded in. If both
|
||||
// the VACUUM INTO snapshot and the checkpoint fail, the -wal/-shm are NOT folded,
|
||||
// so they must be copied alongside the .db — a .db stripped of an un-checkpointed
|
||||
// WAL loses committed transactions, whereas the .db + its WAL is a recoverable set.
|
||||
let foldedIn = false;
|
||||
if (open) {
|
||||
try { open.snapshotInto(path.join(destDir, 'plugin.db')); foldedIn = true; }
|
||||
catch {
|
||||
try { open.checkpoint(); foldedIn = true; } catch { /* WAL not folded — keep sidecars */ }
|
||||
try { fs.copyFileSync(path.join(srcDir, 'plugin.db'), path.join(destDir, 'plugin.db')); }
|
||||
catch { /* unreadable live db — best effort */ }
|
||||
}
|
||||
}
|
||||
for (const f of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (f.name === 'plugin.db' && open) continue; // already snapshotted above
|
||||
// Skip the .db sidecars only when the live handle's WAL was folded in — VACUUM
|
||||
// INTO / checkpoint absorbs them, and copying them out of step with a live writer
|
||||
// is what produced torn restores. For a plugin with NO open handle there is no
|
||||
// writer, so the -wal/-shm are a consistent set with the .db; copy them too, or an
|
||||
// unclean shutdown's committed-but-uncheckpointed transactions (still sitting in
|
||||
// the WAL) would be lost from the backup.
|
||||
if ((f.name.endsWith('-wal') || f.name.endsWith('-shm')) && foldedIn) continue;
|
||||
const src = path.join(srcDir, f.name);
|
||||
const dest = path.join(destDir, f.name);
|
||||
try {
|
||||
if (f.isDirectory()) fs.cpSync(src, dest, { recursive: true });
|
||||
else fs.copyFileSync(src, dest);
|
||||
} catch { /* skip an unreadable entry rather than fail the whole backup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import cron, { type ScheduledTask } from 'node-cron';
|
||||
|
||||
/**
|
||||
* Host-side scheduler for a plugin's declared background jobs (#plugins).
|
||||
*
|
||||
* A plugin declares `jobs` (id + cron `schedule`) in its code; the child reports
|
||||
* them at load. This wires each to node-cron in the HOST and fires
|
||||
* `invoke(id, 'invoke.job', { jobId })` on the tick. Kept deliberately small and
|
||||
* pure so it is unit-testable in isolation of the supervisor.
|
||||
*
|
||||
* Safety: (1) OPT-IN — a job is scheduled only if the admin granted `jobs:run`,
|
||||
* because scheduled work runs with NO acting user and is a distinct risk class
|
||||
* from a user-triggered route. (2) A job carries no user, so every trip read it
|
||||
* attempts is refused by the RPC host — a job can only touch its own db / declared
|
||||
* egress. (3) An invalid cron is skipped (never runs). (4) The tick callback and
|
||||
* `stop()` are wrapped so a throwing job or a stray task can never break the host
|
||||
* or leak across a deactivation.
|
||||
*/
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
schedule: string;
|
||||
}
|
||||
|
||||
/** Grant that must be present for a plugin's jobs to be scheduled at all. */
|
||||
export const JOBS_RUN_PERMISSION = 'jobs:run';
|
||||
|
||||
/**
|
||||
* Schedule every valid-cron job (returns the node-cron tasks to keep for teardown).
|
||||
* Empty when the plugin lacks `jobs:run`, so a plugin without the grant runs nothing.
|
||||
*/
|
||||
export function scheduleJobs(
|
||||
granted: ReadonlySet<string>,
|
||||
jobs: readonly ScheduledJob[],
|
||||
run: (jobId: string) => void,
|
||||
): ScheduledTask[] {
|
||||
if (!granted.has(JOBS_RUN_PERMISSION)) return [];
|
||||
const tasks: ScheduledTask[] = [];
|
||||
for (const job of jobs) {
|
||||
if (!job.schedule || !cron.validate(job.schedule)) continue; // invalid cron -> never runs
|
||||
tasks.push(
|
||||
cron.schedule(job.schedule, () => {
|
||||
try {
|
||||
run(job.id);
|
||||
} catch {
|
||||
/* a job must never break the host loop */
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/** Stop + drop the given tasks (called on deactivation/kill so nothing leaks). */
|
||||
export function stopJobs(tasks: ScheduledTask[] | undefined): void {
|
||||
for (const t of tasks ?? []) {
|
||||
try {
|
||||
t.stop();
|
||||
} catch {
|
||||
/* ignore — teardown must never throw */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Per-plugin RPC rate limiting (#plugins, security hardening).
|
||||
*
|
||||
* Every ctx.* call a plugin makes is dispatched synchronously IN THE HOST process
|
||||
* (better-sqlite3 + the capability router run on the single Node thread), so a
|
||||
* plugin in a tight `while (true) ctx.db.query(...)` loop can starve the whole
|
||||
* instance — including the supervisor's own reap sweep. A token bucket at the
|
||||
* dispatch boundary caps the sustained call rate (with a burst allowance) and an
|
||||
* in-flight counter caps concurrency, turning "one plugin freezes TREK for every
|
||||
* tenant" into "that one plugin gets throttled".
|
||||
*
|
||||
* Pure + dependency-free (a clock is injected) so it unit-tests without timers.
|
||||
*/
|
||||
|
||||
/** A refilling token bucket: `capacity` tokens, refilled at `refillPerSec`. */
|
||||
export class TokenBucket {
|
||||
private tokens: number;
|
||||
private last: number;
|
||||
|
||||
constructor(
|
||||
private readonly capacity: number,
|
||||
private readonly refillPerSec: number,
|
||||
now: number,
|
||||
) {
|
||||
this.tokens = capacity;
|
||||
this.last = now;
|
||||
}
|
||||
|
||||
/** Take one token if available. Returns false when the bucket is empty (throttle). */
|
||||
take(now: number): boolean {
|
||||
// Refill for the elapsed time, capped at capacity.
|
||||
const elapsedSec = Math.max(0, (now - this.last) / 1000);
|
||||
this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSec);
|
||||
this.last = now;
|
||||
if (this.tokens >= 1) {
|
||||
this.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RpcLimitConfig {
|
||||
/** Bucket capacity = burst allowance (calls that can fire back-to-back). */
|
||||
burst: number;
|
||||
/** Sustained calls per second once the burst is spent. */
|
||||
perSec: number;
|
||||
/** Max host→plugin dispatches in flight for one plugin at once. */
|
||||
maxInFlight: number;
|
||||
}
|
||||
|
||||
// Generous defaults: a legitimate plugin handling a request makes a handful of ctx
|
||||
// calls; these only bite a runaway loop. Overridable via env for tuning.
|
||||
export const DEFAULT_RPC_LIMIT: RpcLimitConfig = {
|
||||
burst: Number(process.env.TREK_PLUGIN_RPC_BURST) || 60,
|
||||
perSec: Number(process.env.TREK_PLUGIN_RPC_PER_SEC) || 20,
|
||||
maxInFlight: Number(process.env.TREK_PLUGIN_RPC_INFLIGHT) || 16,
|
||||
};
|
||||
|
||||
/** Per-plugin limiter: a token bucket + an in-flight gauge. */
|
||||
export class RpcRateLimiter {
|
||||
private readonly bucket: TokenBucket;
|
||||
private inFlight = 0;
|
||||
|
||||
constructor(private readonly cfg: RpcLimitConfig, now: number) {
|
||||
this.bucket = new TokenBucket(cfg.burst, cfg.perSec, now);
|
||||
}
|
||||
|
||||
/** True if a call may proceed now (and reserves an in-flight slot). Call `release()`
|
||||
* when the dispatch settles. Returns false when rate- or concurrency-capped. */
|
||||
tryAcquire(now: number): boolean {
|
||||
if (this.inFlight >= this.cfg.maxInFlight) return false;
|
||||
if (!this.bucket.take(now)) return false;
|
||||
this.inFlight += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.inFlight > 0) this.inFlight -= 1;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import {
|
||||
budgetUpdateItemRequestSchema, type BudgetUpdateItemRequest,
|
||||
placeCreateRequestSchema, placeUpdateRequestSchema,
|
||||
dayCreateRequestSchema, dayUpdateRequestSchema,
|
||||
tripUpdateRequestSchema,
|
||||
tripUpdateRequestSchema, tripCreateRequestSchema,
|
||||
reservationCreateRequestSchema, reservationUpdateRequestSchema,
|
||||
reservationEndpointsInputSchema,
|
||||
accommodationCreateRequestSchema, accommodationUpdateRequestSchema,
|
||||
packingCreateItemRequestSchema, packingUpdateItemRequestSchema,
|
||||
collectionCreateRequestSchema, collectionUpdateRequestSchema,
|
||||
collectionSavePlaceRequestSchema, collectionCopyToTripRequestSchema,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
KNOWN_METHODS,
|
||||
@@ -13,6 +19,7 @@ import {
|
||||
type RpcResponse,
|
||||
} from '../protocol/envelope';
|
||||
import type { PluginDataDb } from './plugin-data.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
import { auditResource, isAuditable } from './plugin-audit';
|
||||
|
||||
/**
|
||||
@@ -49,6 +56,24 @@ export interface HostDeps {
|
||||
broadcastToTrip(tripId: number, eventType: string, payload: Record<string, unknown>): void;
|
||||
/** Namespaced per-user broadcast. */
|
||||
broadcastToUser(userId: number, payload: Record<string, unknown>): void;
|
||||
// --- Host-mediated notifications (recipient resolution + channel fan-out owned
|
||||
// by the host; the plugin supplies only target + plain text). ---
|
||||
canAccessTripForNotify(tripId: number, userId: number): boolean;
|
||||
sendPluginNotification(pluginId: string, input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise<unknown>;
|
||||
// --- Host-mediated LLM (the host owns the credential; output is DATA the plugin
|
||||
// must still push through the gated write methods). ---
|
||||
aiConfigured(userId: number): boolean;
|
||||
aiComplete(userId: number, prompt: string, system: string | undefined): Promise<unknown>;
|
||||
aiExtract(userId: number, text: string, jsonSchema: object, prompt: string | undefined): Promise<unknown>;
|
||||
/** The acting user's own value for one of this plugin's `scope:'user'` settings (decrypted). */
|
||||
getUserSetting(pluginId: string, userId: number, key: string): unknown;
|
||||
/** A short-lived OAuth access token for the acting user (host-brokered; refreshes if
|
||||
* expiring). Null when the user hasn't connected. The plugin never sees the refresh token. */
|
||||
getOAuthToken(pluginId: string, userId: number): Promise<string | null>;
|
||||
/** Schedule (upsert by name) a userless future callback; everyMs set = recurring. Caps enforced here. */
|
||||
schedulerSet(name: string, dueAt: number, everyMs: number | undefined, payload: unknown): { scheduled: boolean };
|
||||
/** Cancel a scheduled callback by name. */
|
||||
schedulerCancel(name: string): { cancelled: boolean };
|
||||
/** Optional sink for the capability audit log (host-side, hash-chained). */
|
||||
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void;
|
||||
/** Call an export on another plugin (this host's plugin is the caller). Authorizes
|
||||
@@ -64,6 +89,80 @@ export interface HostDeps {
|
||||
listPackingItems(tripId: number, userId: number): unknown[];
|
||||
/** A trip's files (trash excluded), for `files.list`. */
|
||||
listTripFiles(tripId: number): unknown[];
|
||||
/** One trip file's bytes as base64 (size-capped), for `files.getContent`. Throws if it's not on the trip or exceeds the cap. */
|
||||
getTripFileContent(tripId: number, fileId: number): unknown;
|
||||
// --- Files write (the app's file_upload / file_edit / file_delete permissions) ---
|
||||
canUploadFiles(tripId: number, userId: number): boolean;
|
||||
canEditFiles(tripId: number, userId: number): boolean;
|
||||
canDeleteFiles(tripId: number, userId: number): boolean;
|
||||
/** Store a bounded base64 payload as a trip file (extension + size validated); broadcasts file:created. */
|
||||
createTripFile(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
/** Link an existing trip file to a reservation/place/assignment on the SAME trip. */
|
||||
createTripFileLink(tripId: number, fileId: number, opts: Record<string, unknown>): unknown;
|
||||
/** Update a file's description/links (same-trip targets enforced); broadcasts file:updated. */
|
||||
updateTripFile(tripId: number, fileId: number, input: Record<string, unknown>): unknown;
|
||||
/** Move a trip file to the trash; broadcasts file:deleted. */
|
||||
softDeleteTripFile(tripId: number, fileId: number): unknown;
|
||||
// --- Collab reads (membership + collab addon; no separate right, like the REST GETs) ---
|
||||
listCollabNotes(tripId: number): unknown[];
|
||||
listCollabPolls(tripId: number): unknown[];
|
||||
listCollabMessages(tripId: number, before: number | undefined): unknown[];
|
||||
// --- Collab content (the app's collab_edit permission; collab addon gated) ---
|
||||
canEditCollab(tripId: number, userId: number): boolean;
|
||||
createCollabNote(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
createCollabPoll(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
voteCollabPoll(tripId: number, pollId: number, optionIndex: number, actingUserId: number): unknown;
|
||||
createCollabMessage(tripId: number, text: string, replyTo: number | undefined, actingUserId: number): unknown;
|
||||
// --- Member add (the DISTINCT member_manage permission — never bundled) ---
|
||||
canManageMembers(tripId: number, userId: number): boolean;
|
||||
addTripMember(tripId: number, targetUserId: number, invitedBy: number): unknown;
|
||||
removeTripMember(tripId: number, targetUserId: number, actingUserId: number): unknown;
|
||||
/** The acting user's own journals (journey addon must be enabled). */
|
||||
listJournalsForUser(userId: number): unknown;
|
||||
/** The entries of one of the acting user's journeys (journey addon; access-checked). */
|
||||
journalEntriesForUser(userId: number, journeyId: number): unknown;
|
||||
/** The acting user's visited countries + regions (atlas addon must be enabled). */
|
||||
atlasVisitedForUser(userId: number): unknown;
|
||||
/** The acting user's bucket-list items (atlas addon must be enabled). */
|
||||
atlasBucketForUser(userId: number): unknown[];
|
||||
/** The acting user's vacation plan data (vacay addon must be enabled). */
|
||||
vacayForUser(userId: number): unknown;
|
||||
/** The acting user's saved-place collections (collections addon must be enabled). */
|
||||
listCollectionsForUser(userId: number): unknown;
|
||||
/** One of the acting user's collections by id (collections addon must be enabled). */
|
||||
getCollectionForUser(userId: number, id: number): unknown;
|
||||
// --- Collections write (the service enforces per-collection role itself) ---
|
||||
createCollectionForUser(userId: number, input: Record<string, unknown>): unknown;
|
||||
updateCollectionForUser(userId: number, id: number, input: Record<string, unknown>): unknown;
|
||||
saveCollectionPlace(userId: number, input: Record<string, unknown>): unknown;
|
||||
copyCollectionToTrip(userId: number, input: Record<string, unknown>): unknown;
|
||||
deleteCollectionPlace(userId: number, placeId: number): unknown;
|
||||
// --- Atlas write (all rows are the acting user's own; atlas addon gated) ---
|
||||
markCountryVisited(userId: number, code: string): unknown;
|
||||
unmarkCountryVisited(userId: number, code: string): unknown;
|
||||
markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): unknown;
|
||||
unmarkRegionVisited(userId: number, regionCode: string): unknown;
|
||||
createBucketItem(userId: number, input: Record<string, unknown>): unknown;
|
||||
deleteBucketItem(userId: number, itemId: number): unknown;
|
||||
// --- Vacay write (plan resolved from the acting user; vacay addon gated) ---
|
||||
vacayToggleEntry(userId: number, date: string): unknown;
|
||||
vacayToggleCompanyHoliday(userId: number, date: string, note: string | undefined): unknown;
|
||||
// --- Journal write (journeyService.canEdit self-gates; journey addon gated) ---
|
||||
createJournalEntry(userId: number, journeyId: number, input: Record<string, unknown>): unknown;
|
||||
updateJournalEntry(userId: number, entryId: number, input: Record<string, unknown>): unknown;
|
||||
deleteJournalEntry(userId: number, entryId: number): unknown;
|
||||
/** Create/delete a JOURNAL itself (owned by the acting user) — lets an importer
|
||||
* bootstrap the journal it then fills, and clean it up. */
|
||||
createJournal(userId: number, input: Record<string, unknown>): unknown;
|
||||
deleteJournal(userId: number, journeyId: number): unknown;
|
||||
/** A trip day's notes (trip-scoped), for `daynotes.list`. */
|
||||
listDayNotes(tripId: number, dayId: number): unknown[];
|
||||
/** Create a day note (the day must be on the trip); broadcasts dayNote:created. */
|
||||
createDayNote(tripId: number, dayId: number, input: Record<string, unknown>): unknown;
|
||||
/** Update a day note (scoped to the day+trip); broadcasts dayNote:updated. */
|
||||
updateDayNote(tripId: number, dayId: number, noteId: number, input: Record<string, unknown>): unknown;
|
||||
/** Delete a day note (scoped to the day+trip); broadcasts dayNote:deleted. */
|
||||
deleteDayNote(tripId: number, dayId: number, noteId: number): unknown;
|
||||
/** All budget items of one trip, hydrated with members/payers. */
|
||||
listCostsForTrip(tripId: number): unknown[];
|
||||
/** All budget items across every trip the acting user can access. */
|
||||
@@ -91,6 +190,66 @@ export interface HostDeps {
|
||||
// --- Trip (the 'trip_edit' permission) ---
|
||||
canEditTrip(tripId: number, userId: number): boolean;
|
||||
updateTrip(tripId: number, userId: number, input: Record<string, unknown>): unknown;
|
||||
// --- Trip creation (the 'trip_create' permission; owner = acting user) ---
|
||||
canCreateTrip(userId: number): boolean;
|
||||
createTripForUser(userId: number, input: Record<string, unknown>): unknown;
|
||||
// --- Exchange rates (tenant-free, like weather) ---
|
||||
getRates(base: string): Promise<unknown>;
|
||||
// --- Cross-trip reads (membership baked in — every trip the acting user can access) ---
|
||||
/** Every trip the acting user owns or is a member of (the listTrips baseline). */
|
||||
listTripsForUser(userId: number): unknown[];
|
||||
/** Every reservation across the acting user's accessible trips. */
|
||||
listReservationsForUser(userId: number): unknown[];
|
||||
/** A trip's days with their assignments + day notes — the planner GET's shape. */
|
||||
listTripDays(tripId: number): unknown[];
|
||||
/** A trip's reservations, hydrated like the REST list (endpoints, day_positions, joins). */
|
||||
listTripReservations(tripId: number): unknown[];
|
||||
/** A trip's lodging blocks (day_accommodations) with the joined place fields. */
|
||||
listTripAccommodations(tripId: number): unknown[];
|
||||
// --- Accommodations write (the 'day_edit' permission, like the accommodations REST path) ---
|
||||
/** Create a lodging block (auto-creates its partner hotel reservation + broadcasts); returns it. */
|
||||
createAccommodation(tripId: number, input: Record<string, unknown>): unknown;
|
||||
/** Update a lodging block (syncs the partner reservation); throws if it isn't on the trip. */
|
||||
updateAccommodation(tripId: number, accommodationId: number, input: Record<string, unknown>): unknown;
|
||||
/** Delete a lodging block (cascades the partner reservation/budget row); returns { deleted: true }. */
|
||||
deleteAccommodation(tripId: number, accommodationId: number): unknown;
|
||||
// --- Reservations (the 'reservation_edit' permission) ---
|
||||
canEditReservations(tripId: number, userId: number): boolean;
|
||||
/** Create a reservation (accommodation/budget side effects + broadcasts, as the web app); returns it. */
|
||||
createReservation(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
/** Update a reservation on a trip (same side effects); returns it, or throws if it isn't on the trip. */
|
||||
updateReservation(tripId: number, reservationId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
/** Delete a reservation from a trip (same side effects); returns { deleted: true }. */
|
||||
deleteReservation(tripId: number, reservationId: number, actingUserId: number): unknown;
|
||||
// --- Packing (the 'packing_edit' permission; #858 privacy-scoped broadcasts) ---
|
||||
canEditPacking(tripId: number, userId: number): boolean;
|
||||
/** Create a packing item (owner = acting user); privacy-scoped packing:created broadcast; returns it. */
|
||||
createPackingItem(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
/** Update a packing item; four-case public<->private broadcast; throws if not on the trip. */
|
||||
updatePackingItem(tripId: number, itemId: number, input: Record<string, unknown>, actingUserId: number): unknown;
|
||||
/** Delete a packing item; owner+recipients-scoped packing:deleted broadcast; returns { deleted: true }. */
|
||||
deletePackingItem(tripId: number, itemId: number): unknown;
|
||||
// --- Packing bags (packing_edit; no privacy — broadcast to the whole room) ---
|
||||
listPackingBags(tripId: number): unknown[];
|
||||
createPackingBag(tripId: number, input: Record<string, unknown>): unknown;
|
||||
updatePackingBag(tripId: number, bagId: number, input: Record<string, unknown>): unknown;
|
||||
deletePackingBag(tripId: number, bagId: number): unknown;
|
||||
setPackingBagMembers(tripId: number, bagId: number, userIds: number[]): unknown;
|
||||
// --- Read-convenience: weather (tenant-free), categories (global), the trip roster ---
|
||||
getWeather(lat: number, lng: number, date: string | undefined): unknown;
|
||||
listCategories(): unknown[];
|
||||
tripMembers(tripId: number): unknown[];
|
||||
// --- Tags (the acting user's own; no trip) ---
|
||||
listTagsForUser(userId: number): unknown[];
|
||||
createTagForUser(userId: number, name: string, color: string | undefined): unknown;
|
||||
updateTagForUser(userId: number, tagId: number, name: string | undefined, color: string | undefined): unknown;
|
||||
deleteTagForUser(userId: number, tagId: number): unknown;
|
||||
// --- Todos (core, trip-scoped; the 'packing_edit' permission, like the REST path) ---
|
||||
canEditTodos(tripId: number, userId: number): boolean;
|
||||
listTodos(tripId: number): unknown[];
|
||||
createTodo(tripId: number, input: Record<string, unknown>): unknown;
|
||||
updateTodo(tripId: number, todoId: number, input: Record<string, unknown>): unknown;
|
||||
deleteTodo(tripId: number, todoId: number): unknown;
|
||||
// --- Plugin metadata on core entities (db:meta) ---
|
||||
/** The trip a trip/place/day belongs to (for the membership gate), or undefined. */
|
||||
metaEntityTrip(entityType: string, entityId: number): number | undefined;
|
||||
@@ -116,6 +275,12 @@ export class BadParams extends Error {}
|
||||
// Mirrors the STRING_LIMITS the places REST controller enforces (the @trek/shared
|
||||
// schema doesn't), so the plugin write path rejects the same oversized fields.
|
||||
const PLACE_STR_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
|
||||
// Core entities a plugin may attach its own db:meta to. Each maps to an owning
|
||||
// trip (metaEntityTrip) so the membership + edit gates are the standard ones.
|
||||
const META_ENTITY_TYPES: ReadonlySet<string> = new Set(['trip', 'place', 'day', 'reservation', 'accommodation']);
|
||||
// Same idea for trips — the @trek/shared schema leaves the title/description open,
|
||||
// so mirror the REST controller's field limits on the plugin write path too.
|
||||
const TRIP_STR_LIMITS: Record<string, number> = { title: 200, description: 2000 };
|
||||
|
||||
export class PluginRpcHost {
|
||||
private methods = new Map<string, Handler>();
|
||||
@@ -131,6 +296,7 @@ export class PluginRpcHost {
|
||||
this.methods.set('db.query', (p) => deps.data.query(str(p.sql, 'sql'), asArgs(p.args)));
|
||||
this.methods.set('db.exec', (p) => deps.data.exec(str(p.sql, 'sql'), asArgs(p.args)));
|
||||
this.methods.set('db.migrate', (p) => deps.data.migrate(str(p.id, 'id'), str(p.sql, 'sql')));
|
||||
this.methods.set('db.tx', (p) => deps.data.tx(asTxOps(p.ops)));
|
||||
}
|
||||
|
||||
if (has('db:read:trips')) {
|
||||
@@ -140,9 +306,38 @@ export class PluginRpcHost {
|
||||
this.methods.set('trips.getPlaces', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
// Hydrated like the REST list (endpoints, day_positions, joins, normalized
|
||||
// accommodation_id) — a strict superset of the raw row, so older callers
|
||||
// only ever gain fields.
|
||||
this.methods.set('trips.getReservations', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))),
|
||||
this.tripRead(p, uid, () => deps.listTripReservations(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
// Days with their assignments + day notes: the read half of db:write:days —
|
||||
// without it a writer can't even discover the day ids it may edit.
|
||||
this.methods.set('trips.getDays', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.listTripDays(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
// Lodging blocks (day_accommodations) with their joined place fields. Reads
|
||||
// ride on db:read:trips like every other trip entity: the REST GET, too,
|
||||
// asks only for trip access.
|
||||
this.methods.set('trips.getAccommodations', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.listTripAccommodations(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
// Cross-trip enumeration: every trip the acting user can access. Membership is
|
||||
// baked into listTripsForUser, so there is no tripId to check — but a job/onLoad
|
||||
// (no bound user) is refused, exactly like costs.listMine.
|
||||
this.methods.set('trips.listMine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('trip reads require an authenticated user context');
|
||||
return deps.listTripsForUser(uid);
|
||||
});
|
||||
// Cross-trip reservations feed (dashboards): reservations across every accessible
|
||||
// trip. Same membership predicate + no-user refusal.
|
||||
this.methods.set('reservations.listMine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('reservation reads require an authenticated user context');
|
||||
return deps.listReservationsForUser(uid);
|
||||
});
|
||||
// The trip's member roster (ids + display fields only), membership-checked.
|
||||
this.methods.set('trips.members', (p, uid) => this.tripRead(p, uid, () => deps.tripMembers(num(p.tripId, 'tripId'))));
|
||||
}
|
||||
if (has('db:read:packing')) {
|
||||
// Delegate to the packing service, scoped to the acting user so its #858 private-
|
||||
@@ -157,6 +352,258 @@ export class PluginRpcHost {
|
||||
this.tripRead(p, uid, () => deps.listTripFiles(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
}
|
||||
if (has('db:read:files:content')) {
|
||||
// Reading a file's BYTES is a step up from its metadata (a passport scan is
|
||||
// more sensitive than its filename), so it's a separate grant. Membership-
|
||||
// checked like files.list; the wiring caps the size before base64-ing it
|
||||
// through the IPC pipe.
|
||||
this.methods.set('files.getContent', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.getTripFileContent(num(p.tripId, 'tripId'), num(p.fileId, 'fileId'))),
|
||||
);
|
||||
}
|
||||
if (has('db:write:files')) {
|
||||
// Files write, under the app's separate file_upload / file_edit / file_delete
|
||||
// rights. A created file arrives as bounded base64 (10MB decoded cap — well
|
||||
// under the app's 50MB upload limit); the wiring validates the extension
|
||||
// against the central blocklist before anything touches disk.
|
||||
this.methods.set('files.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'file');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '' || input.name.length > 255) throw new BadParams('file name is required (max 255 chars)');
|
||||
if (typeof input.content_base64 !== 'string' || input.content_base64 === '') throw new BadParams('content_base64 is required');
|
||||
if (input.content_base64.length > 14 * 1024 * 1024) throw new BadParams('file exceeds the 10MB plugin upload cap');
|
||||
this.requireTripEdit(tripId, actor, deps.canUploadFiles);
|
||||
return deps.createTripFile(tripId, input, actor);
|
||||
});
|
||||
this.methods.set('files.createLink', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const fileId = num(p.fileId, 'fileId');
|
||||
const actor = this.requireActor(uid, 'file link');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditFiles);
|
||||
return deps.createTripFileLink(tripId, fileId, asPayload(p.opts));
|
||||
});
|
||||
this.methods.set('files.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const fileId = num(p.fileId, 'fileId');
|
||||
const actor = this.requireActor(uid, 'file');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditFiles);
|
||||
return deps.updateTripFile(tripId, fileId, asPayload(p.input));
|
||||
});
|
||||
this.methods.set('files.softDelete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const fileId = num(p.fileId, 'fileId');
|
||||
const actor = this.requireActor(uid, 'file');
|
||||
this.requireTripEdit(tripId, actor, deps.canDeleteFiles);
|
||||
return deps.softDeleteTripFile(tripId, fileId);
|
||||
});
|
||||
}
|
||||
if (has('db:write:collab')) {
|
||||
// Collab content (notes/polls/messages) under the app's collab_edit right.
|
||||
this.methods.set('collab.createNote', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab note');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.createCollabNote(tripId, input, actor);
|
||||
});
|
||||
this.methods.set('collab.createPoll', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab poll');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required');
|
||||
if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.createCollabPoll(tripId, input, actor);
|
||||
});
|
||||
this.methods.set('collab.votePoll', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab poll');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.voteCollabPoll(tripId, num(p.pollId, 'pollId'), num(p.optionIndex, 'optionIndex'), actor);
|
||||
});
|
||||
this.methods.set('collab.createMessage', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'collab message');
|
||||
if (typeof p.text !== 'string' || p.text.trim() === '' || p.text.length > 4000) throw new BadParams('message text is required (max 4000 chars)');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditCollab);
|
||||
return deps.createCollabMessage(tripId, p.text, typeof p.replyTo === 'number' ? p.replyTo : undefined, actor);
|
||||
});
|
||||
}
|
||||
if (has('db:write:members')) {
|
||||
// Adding a member GRANTS TRIP ACCESS — deliberately its own permission behind
|
||||
// the app's member_manage right (default: trip owner only), never bundled with
|
||||
// a lower-risk write. The acting user is recorded as the inviter.
|
||||
this.methods.set('trips.addMember', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const targetUserId = num(p.userId, 'userId');
|
||||
const actor = this.requireActor(uid, 'trip member');
|
||||
this.requireTripEdit(tripId, actor, deps.canManageMembers);
|
||||
return deps.addTripMember(tripId, targetUserId, actor);
|
||||
});
|
||||
// Symmetry with addMember: a directory-sync integration must be able to reconcile
|
||||
// DEPARTURES, not only additions. Same grant + the same manage-members gate.
|
||||
this.methods.set('trips.removeMember', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const targetUserId = num(p.userId, 'userId');
|
||||
const actor = this.requireActor(uid, 'trip member');
|
||||
this.requireTripEdit(tripId, actor, deps.canManageMembers);
|
||||
return deps.removeTripMember(tripId, targetUserId, actor);
|
||||
});
|
||||
}
|
||||
|
||||
// User-scoped addon reads: the acting user's OWN journals/atlas/vacay across all
|
||||
// their trips (not one trip), so — like costs.listMine — they are gated on a bound
|
||||
// acting user, not a tripId; the wiring additionally refuses a disabled addon.
|
||||
if (has('db:read:collab')) {
|
||||
// Collab reads mirror the REST GETs: membership only (the addon gate lives in
|
||||
// the wiring), no separate right — the write side already has collab_edit.
|
||||
this.methods.set('collab.listNotes', (p, uid) => this.tripRead(p, uid, () => deps.listCollabNotes(num(p.tripId, 'tripId'))));
|
||||
this.methods.set('collab.listPolls', (p, uid) => this.tripRead(p, uid, () => deps.listCollabPolls(num(p.tripId, 'tripId'))));
|
||||
this.methods.set('collab.listMessages', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.listCollabMessages(num(p.tripId, 'tripId'), p.before != null ? num(p.before, 'before') : undefined)),
|
||||
);
|
||||
}
|
||||
if (has('db:read:journal')) {
|
||||
this.methods.set('journal.listMine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('journal reads require an authenticated user context');
|
||||
return deps.listJournalsForUser(uid);
|
||||
});
|
||||
// A journey's entries (photos/story/checkins). Journeys are user-scoped, not
|
||||
// trip-scoped, so the access check is journey membership inside the wiring.
|
||||
this.methods.set('journal.getEntries', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('journal reads require an authenticated user context');
|
||||
return deps.journalEntriesForUser(uid, num(p.journeyId, 'journeyId'));
|
||||
});
|
||||
}
|
||||
if (has('db:read:atlas')) {
|
||||
this.methods.set('atlas.visited', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('atlas reads require an authenticated user context');
|
||||
return deps.atlasVisitedForUser(uid);
|
||||
});
|
||||
this.methods.set('atlas.bucketList', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('atlas reads require an authenticated user context');
|
||||
return deps.atlasBucketForUser(uid);
|
||||
});
|
||||
}
|
||||
if (has('db:read:vacay')) {
|
||||
this.methods.set('vacay.mine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('vacay reads require an authenticated user context');
|
||||
return deps.vacayForUser(uid);
|
||||
});
|
||||
}
|
||||
if (has('db:read:collections')) {
|
||||
this.methods.set('collections.listMine', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('collection reads require an authenticated user context');
|
||||
return deps.listCollectionsForUser(uid);
|
||||
});
|
||||
// getCollection is user-scoped by the service (it takes the acting user), so a
|
||||
// plugin can only fetch a collection the acting user owns.
|
||||
this.methods.set('collections.get', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('collection reads require an authenticated user context');
|
||||
return deps.getCollectionForUser(uid, num(p.id, 'id'));
|
||||
});
|
||||
}
|
||||
if (has('db:write:collections')) {
|
||||
// Collections write. The service enforces per-collection role itself
|
||||
// (owner/admin/editor via assertCanEdit) against the HOST-bound acting user —
|
||||
// the wiring maps its 403/404 to RESOURCE_FORBIDDEN. Inputs are schema-validated.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('collection writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
this.methods.set('collections.create', (p, uid) => {
|
||||
const parsed = collectionCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.createCollectionForUser(requireUid(uid), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.update', (p, uid) => {
|
||||
const parsed = collectionUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid collection: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.updateCollectionForUser(requireUid(uid), num(p.id, 'id'), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.savePlace', (p, uid) => {
|
||||
const parsed = collectionSavePlaceRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.saveCollectionPlace(requireUid(uid), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.copyToTrip', (p, uid) => {
|
||||
const parsed = collectionCopyToTripRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid copy request: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
return deps.copyCollectionToTrip(requireUid(uid), parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('collections.deletePlace', (p, uid) => deps.deleteCollectionPlace(requireUid(uid), num(p.placeId, 'placeId')));
|
||||
}
|
||||
if (has('db:write:atlas')) {
|
||||
// Atlas write: every row is the acting user's own (visited_countries /
|
||||
// visited_regions / bucket) — no trip scoping, no cross-tenant surface.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('atlas writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
const code = (v: unknown, name: string): string => {
|
||||
if (typeof v !== 'string' || v.trim() === '' || v.length > 8) throw new BadParams(`${name} must be a short code`);
|
||||
return v.trim().toUpperCase();
|
||||
};
|
||||
this.methods.set('atlas.markCountry', (p, uid) => deps.markCountryVisited(requireUid(uid), code(p.code, 'code')));
|
||||
this.methods.set('atlas.unmarkCountry', (p, uid) => deps.unmarkCountryVisited(requireUid(uid), code(p.code, 'code')));
|
||||
this.methods.set('atlas.markRegion', (p, uid) => {
|
||||
const u = requireUid(uid);
|
||||
const regionName = typeof p.regionName === 'string' && p.regionName ? p.regionName.slice(0, 128) : String(p.regionCode ?? '');
|
||||
return deps.markRegionVisited(u, code(p.regionCode, 'regionCode'), regionName, code(p.countryCode, 'countryCode'));
|
||||
});
|
||||
this.methods.set('atlas.unmarkRegion', (p, uid) => deps.unmarkRegionVisited(requireUid(uid), code(p.regionCode, 'regionCode')));
|
||||
this.methods.set('atlas.createBucketItem', (p, uid) => {
|
||||
const u = requireUid(uid);
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required');
|
||||
return deps.createBucketItem(u, input);
|
||||
});
|
||||
this.methods.set('atlas.deleteBucketItem', (p, uid) => deps.deleteBucketItem(requireUid(uid), num(p.itemId, 'itemId')));
|
||||
}
|
||||
if (has('db:write:vacay')) {
|
||||
// Vacay write: the plan is resolved host-side from the acting user's active
|
||||
// plan — a plugin can never name another plan. toggleEntry only ever toggles
|
||||
// the ACTING USER's own PTO day.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('vacay writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
const dateStr = (v: unknown): string => {
|
||||
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new BadParams('date must be YYYY-MM-DD');
|
||||
return v;
|
||||
};
|
||||
this.methods.set('vacay.toggleEntry', (p, uid) => deps.vacayToggleEntry(requireUid(uid), dateStr(p.date)));
|
||||
this.methods.set('vacay.toggleCompanyHoliday', (p, uid) =>
|
||||
deps.vacayToggleCompanyHoliday(requireUid(uid), dateStr(p.date), typeof p.note === 'string' ? p.note.slice(0, 256) : undefined));
|
||||
}
|
||||
if (has('db:write:journal')) {
|
||||
// Journal write: journeyService.canEdit self-gates every call against the
|
||||
// acting user (owner/contributor) — the wiring maps a refusal to
|
||||
// RESOURCE_FORBIDDEN. Journeys are user-scoped, not trip-scoped.
|
||||
const requireUid = (uid: number | undefined): number => {
|
||||
if (uid === undefined) throw new ForbiddenResource('journal writes require an authenticated user context');
|
||||
return uid;
|
||||
};
|
||||
this.methods.set('journal.createEntry', (p, uid) => {
|
||||
const u = requireUid(uid);
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required');
|
||||
return deps.createJournalEntry(u, num(p.journeyId, 'journeyId'), input);
|
||||
});
|
||||
this.methods.set('journal.updateEntry', (p, uid) =>
|
||||
deps.updateJournalEntry(requireUid(uid), num(p.entryId, 'entryId'), asPayload(p.input)));
|
||||
this.methods.set('journal.deleteEntry', (p, uid) => deps.deleteJournalEntry(requireUid(uid), num(p.entryId, 'entryId')));
|
||||
this.methods.set('journal.createJourney', (p, uid) => deps.createJournal(requireUid(uid), asPayload(p.input)));
|
||||
this.methods.set('journal.deleteJourney', (p, uid) => deps.deleteJournal(requireUid(uid), num(p.journeyId, 'journeyId')));
|
||||
}
|
||||
if (has('db:read:daynotes')) {
|
||||
// Day notes are trip-scoped (core, no addon), so the standard membership gate applies.
|
||||
this.methods.set('daynotes.list', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.listDayNotes(num(p.tripId, 'tripId'), num(p.dayId, 'dayId'))),
|
||||
);
|
||||
}
|
||||
|
||||
if (has('db:read:costs')) {
|
||||
// "Costs" = budget items (trip-scoped). Same membership gate as trip reads;
|
||||
@@ -307,11 +754,253 @@ export class PluginRpcHost {
|
||||
const actor = this.requireActor(uid, 'trip');
|
||||
const parsed = tripUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.capStrings(parsed.data as Record<string, unknown>, TRIP_STR_LIMITS);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditTrip);
|
||||
return deps.updateTrip(tripId, actor, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:create:trips')) {
|
||||
// Create a brand-new trip owned by the acting user — the capability that
|
||||
// unlocks importers (Google MyMaps, booking dumps, calendar sync). Gated by
|
||||
// the app's own 'trip_create' right + a bound user (a job can't create one).
|
||||
this.methods.set('trips.create', (p, uid) => {
|
||||
const actor = this.requireActor(uid, 'trip');
|
||||
const parsed = tripCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.capStrings(parsed.data as Record<string, unknown>, TRIP_STR_LIMITS);
|
||||
if (!deps.canCreateTrip(actor)) throw new ForbiddenResource('no permission to create trips');
|
||||
return deps.createTripForUser(actor, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('rates:read')) {
|
||||
// Exchange rates are tenant-free (like weather) — a cached upstream feed, no
|
||||
// user or trip. Useful for any plugin that shows or converts money.
|
||||
this.methods.set('rates.get', (p) => deps.getRates(str(p.base, 'base')));
|
||||
}
|
||||
|
||||
if (has('db:write:daynotes')) {
|
||||
// Day notes are edited under the app's 'day_edit' permission (like days). The
|
||||
// wiring verifies the day belongs to the trip, so a plugin can't note a day on
|
||||
// another trip. Text is required; time/icon/sort_order are optional.
|
||||
this.methods.set('daynotes.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const dayId = num(p.dayId, 'dayId');
|
||||
const actor = this.requireActor(uid, 'day note');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.text !== 'string' || input.text.trim() === '') throw new BadParams('note text is required');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.createDayNote(tripId, dayId, input);
|
||||
});
|
||||
this.methods.set('daynotes.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const dayId = num(p.dayId, 'dayId');
|
||||
const noteId = num(p.noteId, 'noteId');
|
||||
const actor = this.requireActor(uid, 'day note');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.updateDayNote(tripId, dayId, noteId, asPayload(p.input));
|
||||
});
|
||||
this.methods.set('daynotes.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const dayId = num(p.dayId, 'dayId');
|
||||
const noteId = num(p.noteId, 'noteId');
|
||||
const actor = this.requireActor(uid, 'day note');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.deleteDayNote(tripId, dayId, noteId);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:reservations')) {
|
||||
// Bookings write. Gated exactly like the reservations REST/MCP path: trip
|
||||
// access + the 'reservation_edit' permission for the HOST-bound acting user.
|
||||
// The delegating deps reuse the real ReservationsService so the accommodation,
|
||||
// budget-sync, notification and broadcast side effects match the web app 1:1.
|
||||
this.methods.set('reservations.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'reservation');
|
||||
const parsed = reservationCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid reservation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
requireValidEndpoints((parsed.data as Record<string, unknown>).endpoints);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditReservations);
|
||||
return deps.createReservation(tripId, parsed.data as Record<string, unknown>, actor);
|
||||
});
|
||||
this.methods.set('reservations.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const reservationId = num(p.reservationId, 'reservationId');
|
||||
const actor = this.requireActor(uid, 'reservation');
|
||||
const parsed = reservationUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid reservation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
requireValidEndpoints((parsed.data as Record<string, unknown>).endpoints);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditReservations);
|
||||
return deps.updateReservation(tripId, reservationId, parsed.data as Record<string, unknown>, actor);
|
||||
});
|
||||
this.methods.set('reservations.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const reservationId = num(p.reservationId, 'reservationId');
|
||||
const actor = this.requireActor(uid, 'reservation');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditReservations);
|
||||
return deps.deleteReservation(tripId, reservationId, actor);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:accommodations')) {
|
||||
// Lodging blocks (day_accommodations). Gated exactly like the accommodations
|
||||
// REST path: trip access + the 'day_edit' permission — NOT reservation_edit;
|
||||
// the blocks live in the day service and REST guards them the same way. The
|
||||
// wiring reuses dayService, so the auto-created partner hotel reservation,
|
||||
// the metadata sync on update and the cascade broadcasts match the web app.
|
||||
this.methods.set('accommodations.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'accommodation');
|
||||
const parsed = accommodationCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid accommodation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.createAccommodation(tripId, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('accommodations.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const accommodationId = num(p.accommodationId, 'accommodationId');
|
||||
const actor = this.requireActor(uid, 'accommodation');
|
||||
const parsed = accommodationUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid accommodation: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.updateAccommodation(tripId, accommodationId, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('accommodations.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const accommodationId = num(p.accommodationId, 'accommodationId');
|
||||
const actor = this.requireActor(uid, 'accommodation');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.deleteAccommodation(tripId, accommodationId);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:packing')) {
|
||||
// Packing list write. Gated exactly like the packing REST path — trip access +
|
||||
// the 'packing_edit' permission for the HOST-bound acting user. The deps reuse
|
||||
// packingService and replicate the #858 privacy-scoped broadcasts 1:1, so a
|
||||
// private item is never leaked to the whole trip room.
|
||||
this.methods.set('packing.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'packing item');
|
||||
const parsed = packingCreateItemRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid packing item: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
return deps.createPackingItem(tripId, parsed.data as Record<string, unknown>, actor);
|
||||
});
|
||||
this.methods.set('packing.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const itemId = num(p.itemId, 'itemId');
|
||||
const actor = this.requireActor(uid, 'packing item');
|
||||
const parsed = packingUpdateItemRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid packing item: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
return deps.updatePackingItem(tripId, itemId, parsed.data as Record<string, unknown>, actor);
|
||||
});
|
||||
this.methods.set('packing.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const itemId = num(p.itemId, 'itemId');
|
||||
const actor = this.requireActor(uid, 'packing item');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
return deps.deletePackingItem(tripId, itemId);
|
||||
});
|
||||
// Bags carry no privacy — a plain packing:bag-* broadcast to the whole room.
|
||||
this.methods.set('packing.listBags', (p, uid) => this.tripRead(p, uid, () => deps.listPackingBags(num(p.tripId, 'tripId'))));
|
||||
this.methods.set('packing.createBag', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'packing bag');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
return deps.createPackingBag(tripId, input);
|
||||
});
|
||||
this.methods.set('packing.updateBag', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const bagId = num(p.bagId, 'bagId');
|
||||
const actor = this.requireActor(uid, 'packing bag');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
return deps.updatePackingBag(tripId, bagId, asPayload(p.input));
|
||||
});
|
||||
this.methods.set('packing.deleteBag', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const bagId = num(p.bagId, 'bagId');
|
||||
const actor = this.requireActor(uid, 'packing bag');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
return deps.deletePackingBag(tripId, bagId);
|
||||
});
|
||||
this.methods.set('packing.setBagMembers', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const bagId = num(p.bagId, 'bagId');
|
||||
const actor = this.requireActor(uid, 'packing bag');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPacking);
|
||||
const raw = asPayload(p).userIds;
|
||||
const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : [];
|
||||
return deps.setPackingBagMembers(tripId, bagId, userIds);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('weather:read')) {
|
||||
// Tenant-free host cache: forecast by coordinates + optional date. No user needed.
|
||||
this.methods.set('weather.get', (p) => deps.getWeather(num(p.lat, 'lat'), num(p.lng, 'lng'), typeof p.date === 'string' ? p.date : undefined));
|
||||
}
|
||||
if (has('db:read:categories')) {
|
||||
// Global, read-only reference list — carries no tenant data.
|
||||
this.methods.set('categories.list', () => deps.listCategories());
|
||||
}
|
||||
if (has('db:read:tags')) {
|
||||
// The acting user's own tags (not trip-scoped) — refuse a userless context.
|
||||
this.methods.set('tags.list', (_p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('tag reads require an authenticated user context');
|
||||
return deps.listTagsForUser(uid);
|
||||
});
|
||||
}
|
||||
if (has('db:write:tags')) {
|
||||
this.methods.set('tags.create', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('tag writes require an authenticated user context');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('tag name is required');
|
||||
return deps.createTagForUser(uid, input.name, typeof input.color === 'string' ? input.color : undefined);
|
||||
});
|
||||
this.methods.set('tags.update', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('tag writes require an authenticated user context');
|
||||
const input = asPayload(p.input);
|
||||
return deps.updateTagForUser(uid, num(p.tagId, 'tagId'), typeof input.name === 'string' ? input.name : undefined, typeof input.color === 'string' ? input.color : undefined);
|
||||
});
|
||||
this.methods.set('tags.delete', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('tag writes require an authenticated user context');
|
||||
return deps.deleteTagForUser(uid, num(p.tagId, 'tagId'));
|
||||
});
|
||||
}
|
||||
if (has('db:read:todos')) {
|
||||
this.methods.set('todos.list', (p, uid) => this.tripRead(p, uid, () => deps.listTodos(num(p.tripId, 'tripId'))));
|
||||
}
|
||||
if (has('db:write:todos')) {
|
||||
// Todos are edited under the app's 'packing_edit' permission (like the REST path).
|
||||
this.methods.set('todos.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'todo');
|
||||
const input = asPayload(p.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('todo name is required');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditTodos);
|
||||
return deps.createTodo(tripId, input);
|
||||
});
|
||||
this.methods.set('todos.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const todoId = num(p.todoId, 'todoId');
|
||||
const actor = this.requireActor(uid, 'todo');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditTodos);
|
||||
return deps.updateTodo(tripId, todoId, asPayload(p.input));
|
||||
});
|
||||
this.methods.set('todos.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const todoId = num(p.todoId, 'todoId');
|
||||
const actor = this.requireActor(uid, 'todo');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditTodos);
|
||||
return deps.deleteTodo(tripId, todoId);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:meta')) {
|
||||
// A plugin's OWN namespaced key/value store attached to a core entity. Not
|
||||
// core data — but the entity must belong to a trip the acting user can
|
||||
@@ -358,6 +1047,92 @@ export class PluginRpcHost {
|
||||
});
|
||||
}
|
||||
|
||||
if (has('notify:send')) {
|
||||
// Host-mediated notification. The plugin supplies only plain text + a target;
|
||||
// the host owns recipient resolution, channel fan-out and per-user preferences.
|
||||
// Recipients are FORCED to the acting user (scope 'user', targetId===uid) or a
|
||||
// trip the acting user is a member of (scope 'trip'); scope 'admin' is refused,
|
||||
// so there is no arbitrary-recipient / admin-broadcast path (no spam, no phishing).
|
||||
this.methods.set('notify.send', (p, uid) => {
|
||||
const actor = this.requireActor(uid, 'notification');
|
||||
const input = asPayload(p.input);
|
||||
// Emoji-stripped so a bell/push notification matches TREK's lucide-only UI (also
|
||||
// trims + collapses whitespace). An all-emoji title collapses to '' → rejected.
|
||||
const title = typeof input.title === 'string' ? stripEmoji(input.title) : '';
|
||||
const body = typeof input.body === 'string' ? stripEmoji(input.body) : '';
|
||||
if (!title || title.length > 200) throw new BadParams('notification title is required (max 200 chars)');
|
||||
if (!body || body.length > 1000) throw new BadParams('notification body is required (max 1000 chars)');
|
||||
const scope = input.scope;
|
||||
if (scope !== 'user' && scope !== 'trip') throw new BadParams("scope must be 'user' or 'trip'");
|
||||
const targetId = num(input.targetId, 'targetId');
|
||||
if (scope === 'user' && targetId !== actor) throw new ForbiddenResource('a plugin may only notify the acting user');
|
||||
if (scope === 'trip' && !deps.canAccessTripForNotify(targetId, actor)) throw new ForbiddenResource('the acting user is not a member of that trip');
|
||||
let link: string | undefined;
|
||||
if (typeof input.link === 'string' && input.link !== '') {
|
||||
// In-app path only. A bare startsWith check misses `/\evil.com`, `/<tab>/…`
|
||||
// etc. that browsers normalize to protocol-relative, so resolve against a
|
||||
// throwaway origin and require the result to stay on it (same rule as the
|
||||
// proxy's toRelativeLocation).
|
||||
let safe: string | null = null;
|
||||
try {
|
||||
const u = new URL(input.link, 'http://x.invalid');
|
||||
if (u.origin === 'http://x.invalid') safe = (u.pathname + u.search + u.hash);
|
||||
} catch { /* invalid → rejected below */ }
|
||||
if (!safe) throw new BadParams('link must be an in-app path starting with /');
|
||||
link = safe.slice(0, 512);
|
||||
}
|
||||
return deps.sendPluginNotification(this.pluginId, { title, body, link, scope, targetId });
|
||||
});
|
||||
}
|
||||
if (has('ai:invoke')) {
|
||||
// Host-mediated LLM. The host holds the (encrypted) credential and runs the
|
||||
// call under the acting user's resolved provider config; the plugin passes only
|
||||
// a prompt / JSON-schema. Output is returned as DATA — never auto-written — so
|
||||
// prompt-injection cannot reach a write without the plugin's own gated call.
|
||||
this.methods.set('ai.complete', (p, uid) => {
|
||||
const actor = this.requireActor(uid, 'AI');
|
||||
if (!deps.aiConfigured(actor)) throw new BadParams('no AI provider is configured for this user');
|
||||
const prompt = typeof p.prompt === 'string' ? p.prompt : '';
|
||||
if (prompt.trim() === '') throw new BadParams('prompt is required');
|
||||
if (prompt.length > 20000) throw new BadParams('prompt exceeds the 20000-char cap');
|
||||
return deps.aiComplete(actor, prompt, typeof p.system === 'string' ? p.system.slice(0, 4000) : undefined);
|
||||
});
|
||||
this.methods.set('ai.extract', (p, uid) => {
|
||||
const actor = this.requireActor(uid, 'AI');
|
||||
if (!deps.aiConfigured(actor)) throw new BadParams('no AI provider is configured for this user');
|
||||
const text = typeof p.text === 'string' ? p.text : '';
|
||||
if (text.trim() === '') throw new BadParams('text is required');
|
||||
if (text.length > 20000) throw new BadParams('text exceeds the 20000-char cap');
|
||||
if (typeof p.jsonSchema !== 'object' || p.jsonSchema === null) throw new BadParams('jsonSchema (an object) is required');
|
||||
return deps.aiExtract(actor, text, p.jsonSchema as object, typeof p.prompt === 'string' ? p.prompt.slice(0, 4000) : undefined);
|
||||
});
|
||||
}
|
||||
if (has('oauth:client')) {
|
||||
// Host-brokered outbound OAuth: return a short-lived access token for the acting
|
||||
// user (the host ran the flow + holds the refresh token). Never yields a token for
|
||||
// a userless context, and the plugin can never reach the refresh token/secret.
|
||||
this.methods.set('oauth.getToken', async (_p, uid) => {
|
||||
// Userless context (a job/scheduled task) → null, matching the SDK contract + mock
|
||||
// ("null when the user hasn't connected or in a userless context"), rather than
|
||||
// throwing RESOURCE_FORBIDDEN, which a background caller can't meaningfully handle.
|
||||
if (uid === undefined) return { accessToken: null };
|
||||
return { accessToken: await deps.getOAuthToken(this.pluginId, uid) };
|
||||
});
|
||||
}
|
||||
|
||||
if (has('jobs:run')) {
|
||||
// Persistent scheduler: a plugin schedules a userless future callback (once or
|
||||
// recurring) that survives restarts. Same grant + risk class as its cron jobs.
|
||||
// Caps are enforced in the wiring (max entries, name/payload size, min interval).
|
||||
this.methods.set('scheduler.set', (p) => {
|
||||
const name = str(p.name, 'name');
|
||||
const dueAt = num(p.dueAt, 'dueAt');
|
||||
const everyMs = p.everyMs != null ? num(p.everyMs, 'everyMs') : undefined;
|
||||
return deps.schedulerSet(name, dueAt, everyMs, p.payload ?? null);
|
||||
});
|
||||
this.methods.set('scheduler.cancel', (p) => deps.schedulerCancel(str(p.name, 'name')));
|
||||
}
|
||||
|
||||
// Inter-plugin capabilities (#plugins deps). Registered UNCONDITIONALLY — there
|
||||
// is no permission for these; the router authorizes each call against the
|
||||
// declared dependency edge + the target's `provides`/`emits` allowlist. The
|
||||
@@ -369,6 +1144,14 @@ export class PluginRpcHost {
|
||||
deps.emitPluginEvent(str(p.event, 'event'), p.payload);
|
||||
return { ok: true };
|
||||
});
|
||||
// The plugin's OWN per-user settings (the acting user's `scope:'user'` values,
|
||||
// decrypted host-side). Unconditional + not sensitive cross-tenant — it only ever
|
||||
// returns THIS plugin's config for the acting user. A userless context (job/onLoad)
|
||||
// has no user, so it returns undefined (the plugin falls back to ctx.config).
|
||||
this.methods.set('settings.get', (p, uid) => {
|
||||
if (uid === undefined) return { value: undefined };
|
||||
return { value: deps.getUserSetting(this.pluginId, uid, str(p.key, 'key')) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,8 +1215,8 @@ export class PluginRpcHost {
|
||||
*/
|
||||
private metaEntity(p: Record<string, unknown>, uid: number | undefined, write: boolean): { entityType: string; entityId: number } {
|
||||
const entityType = str(p.entityType, 'entityType');
|
||||
if (entityType !== 'trip' && entityType !== 'place' && entityType !== 'day') {
|
||||
throw new BadParams(`invalid entityType "${entityType}" (trip|place|day)`);
|
||||
if (!META_ENTITY_TYPES.has(entityType)) {
|
||||
throw new BadParams(`invalid entityType "${entityType}" (${[...META_ENTITY_TYPES].join('|')})`);
|
||||
}
|
||||
const entityId = num(p.entityId, 'entityId');
|
||||
if (uid === undefined) throw new ForbiddenResource('metadata requires an authenticated user context');
|
||||
@@ -443,11 +1226,13 @@ export class PluginRpcHost {
|
||||
}
|
||||
// Reads need trip access; WRITES additionally need the entity's edit permission
|
||||
// — so a read-only member can't overwrite/delete metadata an editor created
|
||||
// (matches how core writes are gated).
|
||||
// (matches how core writes are gated). Accommodations ride on day_edit (like
|
||||
// the accommodation write path), reservations on reservation_edit.
|
||||
if (write) {
|
||||
const canEdit = entityType === 'trip' ? this.deps.canEditTrip
|
||||
: entityType === 'place' ? this.deps.canEditPlaces
|
||||
: this.deps.canEditDays;
|
||||
: entityType === 'reservation' ? this.deps.canEditReservations
|
||||
: this.deps.canEditDays; // day + accommodation
|
||||
if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit ${entityType} ${entityId}`);
|
||||
}
|
||||
return { entityType, entityId };
|
||||
@@ -518,6 +1303,27 @@ function asArgs(v: unknown): unknown[] {
|
||||
if (Array.isArray(v)) return v;
|
||||
throw new BadParams('args must be an array');
|
||||
}
|
||||
/** Coerce a db.tx `ops` param into a validated {sql, args}[] before it reaches the
|
||||
* data db — each op must carry a string sql and, if present, an array of args. */
|
||||
function asTxOps(v: unknown): Array<{ sql: string; args?: unknown[] }> {
|
||||
if (!Array.isArray(v)) throw new BadParams('ops must be an array of { sql, args }');
|
||||
return v.map((op) => {
|
||||
const o = (op ?? {}) as Record<string, unknown>;
|
||||
return { sql: str(o.sql, 'ops[].sql'), args: asArgs(o.args) };
|
||||
});
|
||||
}
|
||||
function asPayload(v: unknown): Record<string, unknown> {
|
||||
return v && typeof v === 'object' ? (v as Record<string, unknown>) : { value: v };
|
||||
}
|
||||
/**
|
||||
* The reservation body is passthrough by contract, but a malformed `endpoints`
|
||||
* array would otherwise fail DEEP in the service (NOT-NULL mid-transaction) or be
|
||||
* dropped silently (missing coords) — both miserable to debug from a plugin. So
|
||||
* the plugin path pins the endpoint shape up front; absent stays absent
|
||||
* (update semantics: omitted = keep, [] = delete all).
|
||||
*/
|
||||
function requireValidEndpoints(v: unknown): void {
|
||||
if (v === undefined) return;
|
||||
const parsed = reservationEndpointsInputSchema.safeParse(v);
|
||||
if (!parsed.success) throw new BadParams(`invalid endpoints: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { pluginsCodeRoot, pluginCodeDir } from '../paths';
|
||||
import { parseJsonText, parseManifest, type PluginManifest } from './manifest';
|
||||
import { scanForNativeBinaries } from './native-scan';
|
||||
import { devLinkEnabled } from '../dev-link';
|
||||
|
||||
/**
|
||||
* Discover plugins placed on the /plugins volume (#plugins, M4, "install from
|
||||
@@ -20,7 +21,27 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
|
||||
if (!fs.existsSync(root)) return { discovered, skipped };
|
||||
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
// A dev-linked plugin is `<root>/<id>` as a symlink (POSIX) / junction (Windows)
|
||||
// pointing at the author's build dir — follow it so it discovers like a real dir.
|
||||
// stat() resolves the link; a dangling/broken link throws and is skipped.
|
||||
let isDir = entry.isDirectory();
|
||||
const full = path.join(root, entry.name);
|
||||
if (!isDir && entry.isSymbolicLink()) {
|
||||
// A dev-link symlink only loads in dev-link mode; a stale link left on the
|
||||
// volume must not be discovered/registered on a normal (non-dev) boot.
|
||||
if (!devLinkEnabled()) continue;
|
||||
try { isDir = fs.statSync(full).isDirectory(); } catch { isDir = false; }
|
||||
} else if (isDir && !devLinkEnabled()) {
|
||||
// On Windows a dev-link is a junction, which Dirent reports as a plain
|
||||
// directory (isSymbolicLink() is false). Detect it the same way: if the
|
||||
// entry resolves outside the plugins volume it is a link, so skip it
|
||||
// unless dev-link mode is on. A normal dir realpaths back to itself.
|
||||
try {
|
||||
if (fs.realpathSync(full) !== path.join(fs.realpathSync(root), entry.name)) continue;
|
||||
} catch { /* unreadable target — leave as a normal dir and let discovery fail loudly */ }
|
||||
}
|
||||
if (!isDir) continue;
|
||||
const dir = pluginCodeDir(entry.name);
|
||||
const manifestPath = path.join(dir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
@@ -25,12 +25,22 @@ export interface WidgetCapability {
|
||||
title?: string;
|
||||
defaultSize?: string;
|
||||
/** Where the widget mounts: dashboard sidebar (default), hero-bar overlay, or the
|
||||
* trip planner's place-detail panel (scoped to the selected place). */
|
||||
slot?: 'sidebar' | 'hero' | 'place-detail';
|
||||
* trip planner's place-detail / day-detail / reservation-detail panel (scoped to
|
||||
* the open place/day/reservation). */
|
||||
slot?: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail';
|
||||
}
|
||||
|
||||
/** How a trip-page plugin sits in the planner's tab bar. */
|
||||
export interface TripPageCapability {
|
||||
/** Core planner tabs hidden while this plugin is active ('plan' can never be replaced). */
|
||||
replaces?: string[];
|
||||
/** Preferred 0-based index for the plugin's tab; omitted = appended after the core tabs. */
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface PluginCapabilities {
|
||||
widget?: WidgetCapability;
|
||||
tripPage?: TripPageCapability;
|
||||
/** Function names this plugin exposes to its dependents via ctx.plugins.call. */
|
||||
provides?: string[];
|
||||
/** Event names this plugin publishes to its dependents via ctx.events.emit. */
|
||||
@@ -192,13 +202,35 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (c.widget && typeof c.widget === 'object') {
|
||||
const w = c.widget as Record<string, unknown>;
|
||||
const slot = optStr(w.slot);
|
||||
if (slot && slot !== 'sidebar' && slot !== 'hero' && slot !== 'place-detail') throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
if (slot && slot !== 'sidebar' && slot !== 'hero' && slot !== 'place-detail' && slot !== 'day-detail' && slot !== 'reservation-detail') throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
out.widget = {
|
||||
title: optStr(w.title),
|
||||
defaultSize: optStr(w.defaultSize),
|
||||
slot: (slot as WidgetCapability['slot']) ?? 'sidebar',
|
||||
};
|
||||
}
|
||||
if (c.tripPage && typeof c.tripPage === 'object') {
|
||||
const tp = c.tripPage as Record<string, unknown>;
|
||||
const page: TripPageCapability = {};
|
||||
if (tp.replaces !== undefined) {
|
||||
if (!Array.isArray(tp.replaces)) throw new ManifestError('capabilities.tripPage.replaces must be an array');
|
||||
const replaces: string[] = [];
|
||||
for (const v of tp.replaces) {
|
||||
if (typeof v !== 'string' || !REPLACEABLE_TABS.includes(v)) {
|
||||
throw new ManifestError(`capabilities.tripPage.replaces: "${String(v)}" is not a replaceable tab (${REPLACEABLE_TABS.join(', ')})`);
|
||||
}
|
||||
if (!replaces.includes(v)) replaces.push(v);
|
||||
}
|
||||
if (replaces.length) page.replaces = replaces;
|
||||
}
|
||||
if (tp.position !== undefined) {
|
||||
if (typeof tp.position !== 'number' || !Number.isInteger(tp.position) || tp.position < 0 || tp.position > 50) {
|
||||
throw new ManifestError('capabilities.tripPage.position must be an integer between 0 and 50');
|
||||
}
|
||||
page.position = tp.position;
|
||||
}
|
||||
if (Object.keys(page).length) out.tripPage = page;
|
||||
}
|
||||
const provides = parseCapabilityNames(c.provides, 'provides');
|
||||
if (provides.length) out.provides = provides;
|
||||
const emits = parseCapabilityNames(c.emits, 'emits');
|
||||
@@ -206,6 +238,11 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Core planner tabs a trip-page plugin may replace while it is active. 'plan' is
|
||||
// deliberately NOT in this list — a trip always keeps its planner view, so a
|
||||
// plugin can take over bookings/transports/…, never the whole trip.
|
||||
const REPLACEABLE_TABS = ['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab'];
|
||||
|
||||
/** Validate a `provides`/`emits` array: de-duplicated, well-formed names. */
|
||||
function parseCapabilityNames(raw: unknown, field: string): string[] {
|
||||
if (raw === undefined) return [];
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { db } from '../../db/database';
|
||||
import { canAccessJourney } from '../../services/journeyService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/journal-entry-rows/:entryId — extra rows for a journal entry,
|
||||
* contributed by plugins implementing the `journalEntryProvider` hook. Additive +
|
||||
* fail-safe like place-details: the entry's journey must be one the caller can
|
||||
* access (owner/contributor — the same gate as the journal detail routes), the
|
||||
* Journey addon must be on (an off addon just yields nothing, this endpoint is
|
||||
* purely additive), each provider runs host->plugin on a short timeout, and one
|
||||
* that errors/times out contributes nothing.
|
||||
*
|
||||
* Unlike place-details, every row is NORMALIZED server-side: strings are
|
||||
* String()-coerced + length-capped, the row count is capped per plugin, and a
|
||||
* row url must be http/https/mailto — a javascript:/data: url rendered as an
|
||||
* <a href> would be click-XSS into the journal page.
|
||||
*/
|
||||
interface EntryRow {
|
||||
label: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
}
|
||||
interface ProviderResult {
|
||||
pluginId: string;
|
||||
items: EntryRow[];
|
||||
}
|
||||
|
||||
const MAX_ROWS = 12; // per provider — bounds the card footprint
|
||||
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
|
||||
|
||||
function safeUrl(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw === '') return undefined;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:' ? raw.slice(0, 2048) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(raw: unknown): EntryRow[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: EntryRow[] = [];
|
||||
for (const r of list) {
|
||||
if (out.length >= MAX_ROWS) break;
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const label = cap(r.label, 60);
|
||||
if (!label) continue; // a row without a label is meaningless — drop it
|
||||
out.push({
|
||||
label,
|
||||
value: r.value != null ? cap(r.value, 200) : undefined,
|
||||
url: safeUrl(r.url),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/journal-entry-rows')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class JournalEntryRowsController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get(':entryId')
|
||||
async get(
|
||||
@Param('entryId') entryIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ providers: ProviderResult[] }> {
|
||||
if (!pluginsEnabled() || !isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] };
|
||||
const entryId = Number(entryIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(entryId) || userId == null) return { providers: [] };
|
||||
|
||||
// The entry's journey must be one the caller can access — same gate as a read.
|
||||
const row = db.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number } | undefined;
|
||||
if (!row || !canAccessJourney(row.journey_id, userId)) return { providers: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('journalEntryProvider');
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id): Promise<ProviderResult | null> => {
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(id, 'journalEntryProvider', 'getRows', [entryId], userId, 5000);
|
||||
const items = normalize(raw);
|
||||
return items.length > 0 ? { pluginId: id, items } : null;
|
||||
} catch {
|
||||
return null; // a slow / failing provider is skipped, never fatal
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { providers: results.filter((r): r is ProviderResult => r !== null) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/map-markers/:tripId — bounded markers plugins overlay on the trip map
|
||||
* via the `mapMarkerProvider` hook (#587 "show bookings on map"). Additive + fail-safe
|
||||
* like the other provider-hook controllers: the caller must access the trip, each
|
||||
* provider runs host->plugin on a short timeout, and one that errors/times out
|
||||
* contributes nothing.
|
||||
*
|
||||
* DECLARATIVE ONLY — a plugin never runs JS on the map canvas; it returns marker
|
||||
* specs the host draws. Every field is normalized here exactly like view-contributions:
|
||||
* coordinates are range-checked, strings are String()-coerced + length-capped,
|
||||
* icon/tone are enum-whitelisted, the popup url must be http/https/mailto (a
|
||||
* javascript:/data: url would be click-XSS), and the marker count is capped per plugin.
|
||||
*/
|
||||
type Tone = 'default' | 'success' | 'warn' | 'danger';
|
||||
interface MapMarker {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label?: string;
|
||||
popupText?: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
tone: Tone;
|
||||
}
|
||||
|
||||
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
|
||||
const MAX_MARKERS = 200; // per provider — bounds the render cost
|
||||
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
|
||||
|
||||
function safeUrl(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw === '') return undefined;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:' ? raw.slice(0, 2048) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(pluginId: string, raw: unknown): MapMarker[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: MapMarker[] = [];
|
||||
for (const m of list) {
|
||||
if (out.length >= MAX_MARKERS) break;
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
const lat = Number(m.lat);
|
||||
const lng = Number(m.lng);
|
||||
const id = cap(m.id, 64);
|
||||
// A marker with no id or out-of-range coordinates is meaningless — drop it.
|
||||
if (!id || !Number.isFinite(lat) || !Number.isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) continue;
|
||||
out.push({
|
||||
pluginId,
|
||||
id,
|
||||
lat,
|
||||
lng,
|
||||
label: m.label != null ? cap(m.label, 80) : undefined,
|
||||
popupText: m.popupText != null ? cap(m.popupText, 280) : undefined,
|
||||
url: safeUrl(m.url),
|
||||
icon: m.icon != null ? cap(m.icon, 40) : undefined,
|
||||
// Check the RAW value against the enum (not String(m.tone)) — otherwise an object
|
||||
// with a matching toString() passes the guard but the raw object is emitted as the
|
||||
// tone, and a non-string tone crashes the client that renders it.
|
||||
tone: TONES.has(m.tone as string) ? (m.tone as Tone) : 'default',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/map-markers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MapMarkersController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get(':tripId')
|
||||
async get(
|
||||
@Param('tripId') tripIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ markers: MapMarker[] }> {
|
||||
if (!pluginsEnabled()) return { markers: [] };
|
||||
const tripId = Number(tripIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(tripId) || userId == null || !canAccessTrip(tripId, userId)) return { markers: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('mapMarkerProvider');
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<MapMarker[]> => {
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(id, 'mapMarkerProvider', 'getMarkers', [tripId], userId, 5000);
|
||||
return normalize(id, raw);
|
||||
} catch {
|
||||
return []; // a slow / failing provider contributes nothing
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { markers: perProvider.flat() };
|
||||
}
|
||||
}
|
||||
@@ -110,8 +110,20 @@ export function serverCodeRoot(): string {
|
||||
* out, closing the direct-filesystem and RCE escapes that bypass the RPC layer.
|
||||
* Empty (opt-out) when TREK_PLUGIN_PERMISSIONS=off.
|
||||
*/
|
||||
let warnedPermissionsOff = false;
|
||||
export function pluginPermissionArgs(pluginId: string): string[] {
|
||||
if ((process.env.TREK_PLUGIN_PERMISSIONS ?? 'on').toLowerCase() === 'off') return [];
|
||||
if ((process.env.TREK_PLUGIN_PERMISSIONS ?? 'on').toLowerCase() === 'off') {
|
||||
// The OS permission jail is the boundary that stops an installed plugin from
|
||||
// reading trek.db / the secret files / shelling out. Turning it off makes plugin
|
||||
// isolation crash-only — surface that loudly once so an operator can't leave it
|
||||
// off by accident.
|
||||
if (!warnedPermissionsOff) {
|
||||
warnedPermissionsOff = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[plugins] TREK_PLUGIN_PERMISSIONS=off — the OS permission jail is DISABLED; installed plugins run with full Node access to this process. Only use this on a machine you fully trust.');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const codeRoot = serverCodeRoot();
|
||||
return [
|
||||
'--permission',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/pdf-sections/:tripId — text-only sections plugins append to a trip's
|
||||
* PDF export via the `pdfSectionProvider` hook. Additive + fail-safe like the
|
||||
* other provider-hook controllers: the caller must access the trip, each provider
|
||||
* runs host->plugin on a short timeout, and one that errors/times out contributes
|
||||
* nothing.
|
||||
*
|
||||
* DECLARATIVE ONLY — a plugin never renders into the document; it returns plain
|
||||
* strings the export escapes and lays out itself. Everything is normalized here:
|
||||
* strings are String()-coerced + length-capped, paragraph/row/header counts are
|
||||
* capped, table rows are clipped to the header width, and the section count is
|
||||
* capped per plugin. No urls in a PDF, so there is nothing to allowlist.
|
||||
*/
|
||||
interface PdfTable {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
interface PdfSection {
|
||||
pluginId: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
table?: PdfTable;
|
||||
}
|
||||
|
||||
const MAX_SECTIONS = 5; // per provider — bounds the export size
|
||||
const MAX_PARAGRAPHS = 20;
|
||||
const MAX_HEADERS = 8;
|
||||
const MAX_ROWS = 50;
|
||||
const TITLE_MAX = 120;
|
||||
const PARAGRAPH_MAX = 2000;
|
||||
const HEADER_MAX = 60;
|
||||
const CELL_MAX = 200;
|
||||
|
||||
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
|
||||
|
||||
function normalizeTable(raw: unknown): PdfTable | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const t = raw as Record<string, unknown>;
|
||||
const headers = (Array.isArray(t.headers) ? t.headers : []).slice(0, MAX_HEADERS).map((h) => cap(h, HEADER_MAX));
|
||||
// A table without headers has no width to clip rows to — drop it.
|
||||
if (headers.length === 0) return undefined;
|
||||
const rows = (Array.isArray(t.rows) ? t.rows : [])
|
||||
.slice(0, MAX_ROWS)
|
||||
.map((row) => (Array.isArray(row) ? row : []).slice(0, headers.length).map((c) => cap(c, CELL_MAX)));
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function normalize(pluginId: string, raw: unknown): PdfSection[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: PdfSection[] = [];
|
||||
for (const s of list) {
|
||||
if (out.length >= MAX_SECTIONS) break;
|
||||
if (!s || typeof s !== 'object') continue;
|
||||
const title = cap(s.title, TITLE_MAX);
|
||||
if (!title) continue; // a section without a heading is meaningless — drop it
|
||||
out.push({
|
||||
pluginId,
|
||||
title,
|
||||
paragraphs: (Array.isArray(s.paragraphs) ? s.paragraphs : []).slice(0, MAX_PARAGRAPHS).map((p) => cap(p, PARAGRAPH_MAX)),
|
||||
table: normalizeTable(s.table),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/pdf-sections')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PdfSectionsController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get(':tripId')
|
||||
async get(
|
||||
@Param('tripId') tripIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ sections: PdfSection[] }> {
|
||||
if (!pluginsEnabled()) return { sections: [] };
|
||||
const tripId = Number(tripIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(tripId) || userId == null || !canAccessTrip(tripId, userId)) return { sections: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('pdfSectionProvider');
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<PdfSection[]> => {
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(id, 'pdfSectionProvider', 'getSections', [tripId], userId, 5000);
|
||||
return normalize(id, raw);
|
||||
} catch {
|
||||
return []; // a slow / failing provider contributes nothing
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { sections: perProvider.flat() };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { db, canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/place-details/:placeId — extra info for a place, contributed by plugins
|
||||
@@ -11,10 +12,50 @@ import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
* the place must belong to a trip the caller can access, each provider is called
|
||||
* host→plugin with a short timeout, and a provider that errors or times out is
|
||||
* simply skipped — it never delays or breaks the place panel.
|
||||
*
|
||||
* Every row is NORMALIZED server-side (same as journal-entry-rows): strings are
|
||||
* String()-coerced + length-capped, the row count is capped per plugin, and a
|
||||
* row url must be http/https/mailto — a javascript:/data: url rendered as an
|
||||
* <a href> would be click-XSS into the place panel.
|
||||
*/
|
||||
interface DetailItem {
|
||||
label: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
}
|
||||
interface ProviderResult {
|
||||
pluginId: string;
|
||||
items: unknown;
|
||||
items: DetailItem[];
|
||||
}
|
||||
|
||||
const MAX_ITEMS = 12; // per provider — bounds the panel footprint
|
||||
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
|
||||
|
||||
function safeUrl(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw === '') return undefined;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'mailto:' ? raw.slice(0, 2048) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(raw: unknown): DetailItem[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: DetailItem[] = [];
|
||||
for (const r of list) {
|
||||
if (out.length >= MAX_ITEMS) break;
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const label = cap(r.label, 60);
|
||||
if (!label) continue; // a row without a label is meaningless — drop it
|
||||
out.push({
|
||||
label,
|
||||
value: r.value != null ? cap(r.value, 200) : undefined,
|
||||
url: safeUrl(r.url),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/place-details')
|
||||
@@ -40,8 +81,9 @@ export class PlaceDetailsController {
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id): Promise<ProviderResult | null> => {
|
||||
try {
|
||||
const items = await this.runtime.invokeHook(id, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000);
|
||||
return { pluginId: id, items };
|
||||
const raw = await this.runtime.invokeHook(id, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000);
|
||||
const items = normalize(raw);
|
||||
return items.length > 0 ? { pluginId: id, items } : null;
|
||||
} catch {
|
||||
return null; // a slow / failing provider is skipped, never fatal
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { db } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { readAuditForUser } from './host/plugin-audit';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-activity — the authenticated user's OWN plugin activity log:
|
||||
* every host-mediated action a plugin took while bound to them, across all
|
||||
* plugins, newest first. This is the user-facing half of the hash-chained audit
|
||||
* (the admin sees the per-plugin view); it's what makes the deliberately broad
|
||||
* read grants accountable to the person whose data is read.
|
||||
*
|
||||
* Own path (not under /api/plugins/:id) so it can never be shadowed by the plugin
|
||||
* proxy route. Not admin-gated — every user may see what was done in their name.
|
||||
*/
|
||||
@Controller('api/plugin-activity')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginActivityController {
|
||||
@Get()
|
||||
mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): { activity: unknown[] } {
|
||||
if (!pluginsEnabled()) return { activity: [] };
|
||||
const userId = req.user?.id;
|
||||
if (userId == null) return { activity: [] };
|
||||
// Math.floor so a non-integer (e.g. ?limit=2.5) can't reach SQLite's LIMIT and 500;
|
||||
// Math.floor(NaN) stays NaN so the `|| 200` fallback still applies.
|
||||
const limit = Math.min(Math.max(Math.floor(Number(limitRaw)) || 200, 1), 500);
|
||||
return { activity: readAuditForUser(db, userId, limit) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pluginsCodeRoot, pluginsDataRoot } from './paths';
|
||||
|
||||
/**
|
||||
* Backup/restore of the plugin trees (#plugins). A TREK backup archives travel.db +
|
||||
* uploads + the encryption key, but a plugin's per-plugin SQLite file (its ONLY copy
|
||||
* of the user data it holds) and its installed code live in separate trees — without
|
||||
* these, a restored instance has the `plugins` rows but none of their data or code.
|
||||
*
|
||||
* The tricky half is restore: the HOST holds each plugin DB open (better-sqlite3), so
|
||||
* overwriting those files live is unsafe (and on Windows, locked). Instead restore
|
||||
* STAGES the extracted trees next to the live ones, and this module swaps them in at
|
||||
* the next boot BEFORE the runtime opens anything — the same "applies on restart" model
|
||||
* the bundled encryption key already uses. No plugin quiesce, no swap under open
|
||||
* handles, no new admin setup.
|
||||
*/
|
||||
|
||||
const STAGE_SUFFIX = '.restore';
|
||||
|
||||
function dataStaging(): string { return pluginsDataRoot() + STAGE_SUFFIX; }
|
||||
function codeStaging(): string { return pluginsCodeRoot() + STAGE_SUFFIX; }
|
||||
|
||||
/**
|
||||
* Copy the plugin trees an archive extracted (under `extractDir/plugins-data` and
|
||||
* `.../plugins-code`) into staging dirs beside the live trees. cpSync (not rename) so
|
||||
* it works even when the plugin volumes sit on a different filesystem than the extract
|
||||
* dir. A no-op for a backup that carries no plugin trees (older archives). Returns true
|
||||
* if anything was staged (so restore can tell the admin a restart is needed to finish).
|
||||
*
|
||||
* Staging is made atomic with a `.tmp` sibling: the (interruptible) copy lands in
|
||||
* `<root>.restore.tmp`, and only a fully-copied tree is renamed to `<root>.restore`.
|
||||
* The rename is same-directory (same filesystem), so it's atomic. Without this a copy
|
||||
* that dies partway — disk full, OOM, a crash — would leave a PARTIAL `.restore`, and
|
||||
* the next boot's swap deletes every live plugin dir not present in it: data loss, even
|
||||
* though the restore reported success. A leftover `.tmp` is inert (the apply path only
|
||||
* looks for `.restore`) and is cleared on the next staging.
|
||||
*/
|
||||
export function stageExtractedPluginTrees(extractDir: string): boolean {
|
||||
let staged = false;
|
||||
const pairs: Array<[string, string]> = [
|
||||
[path.join(extractDir, 'plugins-data'), dataStaging()],
|
||||
[path.join(extractDir, 'plugins-code'), codeStaging()],
|
||||
];
|
||||
for (const [from, to] of pairs) {
|
||||
if (!fs.existsSync(from)) continue;
|
||||
const tmp = to + '.tmp';
|
||||
fs.rmSync(to, { recursive: true, force: true }); // drop a stale completed staging
|
||||
fs.rmSync(tmp, { recursive: true, force: true }); // drop a stale partial staging
|
||||
fs.cpSync(from, tmp, { recursive: true }); // may die partway → only .tmp is left, never .restore
|
||||
fs.renameSync(tmp, to); // atomic: publishes a COMPLETE staging
|
||||
staged = true;
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the CONTENTS of `live` with `staged`, entry by entry — never renaming the
|
||||
* root itself, because a root that is a bind/volume mount point can't be renamed
|
||||
* (EBUSY) or moved across a filesystem (EXDEV). Existing DEV-LINK entries in `live`
|
||||
* (a plugin dir symlinked/junctioned to an author's source, which the backup deliberately
|
||||
* excluded) are preserved, so a same-instance backup→restore round trip doesn't destroy
|
||||
* them. Same-fs renames where possible, copy+remove for the cross-fs case.
|
||||
*/
|
||||
function swapContents(live: string, staged: string): void {
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
const realLive = fs.realpathSync(live);
|
||||
const stagedNames = new Set(fs.readdirSync(staged));
|
||||
// COPY (not move) each staged entry over the live one, leaving `staged` intact until the
|
||||
// very end. This is the key to crash-safety: `staged` stays the complete source of truth
|
||||
// for the whole operation, so if the process dies mid-swap (power loss, OOM, an exit-hook
|
||||
// throw) the next boot re-runs swapContents and re-copies everything correctly — nothing
|
||||
// is ever left half-deleted. (A move/rename would empty `staged` as it went, so a retry
|
||||
// could no longer restore an already-moved entry it had just deleted from `live`.)
|
||||
for (const name of stagedNames) {
|
||||
const to = path.join(live, name);
|
||||
fs.rmSync(to, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(staged, name), to, { recursive: true });
|
||||
}
|
||||
// Remove live entries NOT in the backup (plugins uninstalled since it was taken), keeping
|
||||
// dev-links (realpath points outside the root). Safe now that every backup entry is in place.
|
||||
for (const name of fs.readdirSync(live)) {
|
||||
if (stagedNames.has(name)) continue;
|
||||
const p = path.join(live, name);
|
||||
let real: string;
|
||||
try { real = fs.realpathSync(p); } catch { real = p; }
|
||||
if (real !== p && !real.startsWith(realLive + path.sep)) continue; // dev-link → keep
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
// Only NOW drop staging — the single point of no return, after `live` is fully correct.
|
||||
fs.rmSync(staged, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* If a prior restore staged plugin trees, swap them into place. Applied ONCE — either
|
||||
* immediately by the restore (via the applier below, after it quiesces the plugins so
|
||||
* their DB handles are closed) or, if the runtime wasn't up, at the next boot BEFORE the
|
||||
* runtime opens any plugin DB. Content-level swap (see swapContents) so a volume-mounted
|
||||
* root is safe and dev-links survive. Never throws — a reconcile hiccup must not stop the
|
||||
* server booting. Returns the labels of trees it applied, for logging.
|
||||
*/
|
||||
export function applyStagedPluginTrees(): string[] {
|
||||
const applied: string[] = [];
|
||||
const pairs: Array<[string, string, string]> = [
|
||||
['plugins-data', pluginsDataRoot(), dataStaging()],
|
||||
['plugins-code', pluginsCodeRoot(), codeStaging()],
|
||||
];
|
||||
for (const [label, live, staged] of pairs) {
|
||||
if (!fs.existsSync(staged)) continue;
|
||||
try {
|
||||
swapContents(live, staged);
|
||||
applied.push(label);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[plugins] failed to apply staged ${label} restore:`, err);
|
||||
}
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
// A restore can't swap the plugin trees while the runtime holds their DB handles open,
|
||||
// and it must NOT leave the swap for an arbitrary future boot (by then the live data has
|
||||
// diverged, so applying stale staged data would silently revert it and resurrect erased
|
||||
// rows). So the runtime registers an applier here that QUIESCES the plugins (closing the
|
||||
// handles) and applies the swap right away; the restore calls it the moment it finishes
|
||||
// staging. If the runtime isn't up, staging simply waits for the boot reconcile — with no
|
||||
// running plugins, there is nothing to diverge.
|
||||
let applier: (() => void | Promise<void>) | null = null;
|
||||
export function setStagedRestoreApplier(fn: (() => void | Promise<void>) | null): void {
|
||||
applier = fn;
|
||||
}
|
||||
export async function applyStagedRestoreNow(): Promise<boolean> {
|
||||
if (!applier) return false;
|
||||
try {
|
||||
await applier();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[plugins] immediate staged-restore apply failed; will retry on next boot:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* Calendar events contributed by plugins that implement the `calendarSource` hook
|
||||
* (needs `hook:calendar-source`). The core consumer that makes the hook LIVE:
|
||||
* GET /api/plugin-calendar?start=<ISO>&end=<ISO> — aggregate events for the signed-in
|
||||
* user across every calendar-source plugin, for embedding in a calendar view or an
|
||||
* external subscription. Additive + fail-safe: each source is called host→plugin on a
|
||||
* short timeout, one that errors/times out is skipped. Every field is NORMALIZED —
|
||||
* strings length-capped, dates kept as ISO strings, the event count capped per source.
|
||||
*/
|
||||
interface DevEvent { id: string; pluginId: string; source: string; title: string; start: string; end: string; allDay: boolean; }
|
||||
|
||||
const MAX_EVENTS = 500; // per source per request
|
||||
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
|
||||
const isoish = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 && v.length <= 40 ? v : undefined);
|
||||
|
||||
function normalizeEvents(pluginId: string, source: string, raw: unknown): DevEvent[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: DevEvent[] = [];
|
||||
for (const e of list) {
|
||||
if (out.length >= MAX_EVENTS) break;
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const id = cap(e.id, 256);
|
||||
const title = cap(e.title, 300);
|
||||
const start = isoish(e.start);
|
||||
const end = isoish(e.end);
|
||||
if (!id || !title || !start || !end) continue; // an event missing its core fields is dropped
|
||||
out.push({ id, pluginId, source, title, start, end, allDay: e.allDay === true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/plugin-calendar')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginCalendarController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get()
|
||||
async get(
|
||||
@Query('start') start: string | undefined,
|
||||
@Query('end') end: string | undefined,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ sources: Array<{ pluginId: string; name: string; events: DevEvent[] }> }> {
|
||||
if (!pluginsEnabled()) return { sources: [] };
|
||||
const userId = req.user?.id;
|
||||
if (userId == null) return { sources: [] };
|
||||
// Default to a generous window (90 days back → ~1 year out) when unspecified.
|
||||
const s = isoish(start) ?? new Date(Date.now() - 90 * 864e5).toISOString();
|
||||
const e = isoish(end) ?? new Date(Date.now() + 366 * 864e5).toISOString();
|
||||
|
||||
const ids = this.runtime.providersOf('calendarSource');
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
const [rawName, rawEvents] = await Promise.all([
|
||||
this.runtime.invokeHook(id, 'calendarSource', 'getName', [], userId, 3000).catch(() => id),
|
||||
this.runtime.invokeHook(id, 'calendarSource', 'getEvents', [userId, s, e], userId, 5000),
|
||||
]);
|
||||
const name = cap(rawName, 80) || id;
|
||||
const events = normalizeEvents(id, name, rawEvents);
|
||||
return events.length > 0 ? { pluginId: id, name, events } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { sources: results.filter((r): r is { pluginId: string; name: string; events: DevEvent[] } => r !== null) };
|
||||
}
|
||||
}
|
||||
@@ -61,30 +61,42 @@ export class PluginFrameController {
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId));
|
||||
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId, req.get('host')));
|
||||
res.sendFile(resolved);
|
||||
}
|
||||
|
||||
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
|
||||
private frameCsp(pluginId: string): string {
|
||||
private frameCsp(pluginId: string, host: string | undefined): string {
|
||||
// Defense in depth: the manifest validator already constrains these hosts, but
|
||||
// never interpolate anything that isn't a clean host/wildcard into connect-src
|
||||
// (a stray space or `*` would inject an extra CSP source token).
|
||||
const outbound = this.runtime
|
||||
.outboundHostsOf(pluginId)
|
||||
.filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
|
||||
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
|
||||
// 'self' matches nothing and the plugin's own <script src>/<link> files would
|
||||
// be blocked — a multi-file client build (Vite/React output) only worked
|
||||
// inlined. A scheme-less host-source pinned to THIS plugin's frame path
|
||||
// re-allows exactly its own assets and still no other host. Both interpolated
|
||||
// parts are charset-checked so a stray token can't widen the policy (the
|
||||
// Host header is client-controlled; a forged one only lames the forger's own
|
||||
// response), and a missing/odd Host just falls back to inline-only.
|
||||
const ownAssets =
|
||||
host && /^[a-z0-9.-]+(:\d+)?$/i.test(host) && /^[a-z][a-z0-9-]{2,39}$/.test(pluginId)
|
||||
? ` ${host}/plugin-frame/${pluginId}/`
|
||||
: '';
|
||||
const connect = ["'self'", ...outbound.map((h) => `https://${h}`)].join(' ');
|
||||
return [
|
||||
"default-src 'none'",
|
||||
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin),
|
||||
// so 'self' matches nothing — inline is the only way its own script can run.
|
||||
// Safe here: the sandbox (not this script-src) is the isolation boundary,
|
||||
// and the plugin author controls the frame's code either way.
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src ${connect}`,
|
||||
// 'unsafe-inline' is safe here: the sandbox (not this script-src) is the
|
||||
// isolation boundary, and the plugin author controls the frame's code
|
||||
// either way. What script-src must keep doing is denying REMOTE hosts —
|
||||
// a script URL is an egress channel connect-src never sees.
|
||||
`script-src 'self' 'unsafe-inline'${ownAssets}`,
|
||||
`style-src 'self' 'unsafe-inline'${ownAssets}`,
|
||||
`img-src 'self' data: blob:${ownAssets}`,
|
||||
`font-src 'self' data:${ownAssets}`,
|
||||
`connect-src ${connect}${ownAssets}`,
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginOAuthService } from './plugin-oauth.service';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
/**
|
||||
* Host-brokered outbound OAuth endpoints (#plugins). All are gated by JwtAuthGuard —
|
||||
* the browser carries the session, so the acting user is the real logged-in user, and
|
||||
* `state` additionally binds the callback to the connect request (CSRF defence). The
|
||||
* refresh token + client secret never leave the host; the plugin reads only a
|
||||
* short-lived access token at runtime via `ctx.oauth.getAccessToken()`.
|
||||
*/
|
||||
@Controller('api/plugin-oauth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginOAuthController {
|
||||
constructor(private readonly oauth: PluginOAuthService) {}
|
||||
|
||||
private isActive(id: string): boolean {
|
||||
return !!db.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
|
||||
}
|
||||
|
||||
@Get(':id/status')
|
||||
status(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { configured: boolean; connected: boolean } {
|
||||
const userId = req.user?.id;
|
||||
if (!pluginsEnabled() || userId == null || !this.isActive(id)) return { configured: false, connected: false };
|
||||
return this.oauth.status(id, userId);
|
||||
}
|
||||
|
||||
@Post(':id/connect')
|
||||
connect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { authorizeUrl: string } {
|
||||
const userId = req.user?.id;
|
||||
if (!pluginsEnabled() || userId == null || !this.isActive(id)) throw new Error('plugin not available');
|
||||
return { authorizeUrl: this.oauth.startConnect(id, userId, Date.now()) };
|
||||
}
|
||||
|
||||
@Get(':id/callback')
|
||||
async callback(
|
||||
@Param('id') id: string,
|
||||
@Query('code') code: string | undefined,
|
||||
@Query('state') state: string | undefined,
|
||||
@Query('error') error: string | undefined,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const userId = req.user?.id;
|
||||
const back = (status: string) => res.redirect(`/settings?oauth=${encodeURIComponent(id)}:${status}`);
|
||||
if (!pluginsEnabled() || userId == null || !this.isActive(id)) return back('unavailable');
|
||||
if (error || !code || !state) return back('denied');
|
||||
try {
|
||||
await this.oauth.completeCallback(id, userId, code, state, Date.now());
|
||||
return back('connected');
|
||||
} catch {
|
||||
return back('failed');
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':id/disconnect')
|
||||
disconnect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { connected: false } {
|
||||
const userId = req.user?.id;
|
||||
if (userId != null) this.oauth.disconnect(id, userId);
|
||||
return { connected: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../../db/database';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { getAppUrl } from '../../services/notifications';
|
||||
import { isPrivateIp } from './install/safe-fetch';
|
||||
import { safeFetchLlm } from '../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a
|
||||
* third-party service; the HOST runs the whole flow (authorize -> callback -> token
|
||||
* exchange -> refresh) with PKCE + state and HOLDS the tokens. The plugin only ever
|
||||
* triggers "connect" and reads a short-lived access token via `ctx.oauth.getAccessToken()`
|
||||
* — it never sees the refresh token or the client secret.
|
||||
*
|
||||
* Provider config (endpoints + client credentials) is the plugin's admin-owned
|
||||
* INSTANCE settings — a plugin declares these `scope:'instance'` fields and the admin
|
||||
* fills them in:
|
||||
* oauth_authorize_url, oauth_token_url, oauth_scopes (optional), and the two secrets
|
||||
* oauth_client_id, oauth_client_secret.
|
||||
* Tokens are per-user + encrypted at rest; the PKCE verifier/state row is short-lived.
|
||||
*/
|
||||
export interface OAuthProviderConfig {
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
scopes: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000; // an authorize round-trip must finish within 10 min
|
||||
const REFRESH_SKEW_S = 60; // refresh a token expiring within a minute
|
||||
|
||||
function b64url(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Cheap fast-fail for an obviously-internal token endpoint. This is a pre-check, not the
|
||||
* authoritative gate: the real SSRF defence is the DNS-resolving, IP-pinning guard inside
|
||||
* the fetch (tokenRequest), which blocks the cloud-metadata range even for a DNS name. This
|
||||
* rejects the literal loopback / link-local / metadata hosts (v4 AND v6) plus the internal
|
||||
* name suffixes, so a bracketed IPv6 literal or a `.internal` name can't slip past the
|
||||
* fast-fail. Private LAN (10./192.168./…) is deliberately left to the fetch policy so a
|
||||
* self-hosted internal IdP stays reachable. */
|
||||
function assertSafeHttps(urlStr: string, what: string): URL {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(urlStr);
|
||||
} catch {
|
||||
throw new Error(`${what} is not a valid URL`);
|
||||
}
|
||||
if (u.protocol !== 'https:') throw new Error(`${what} must be https`);
|
||||
const host = u.hostname.toLowerCase();
|
||||
const ip = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
||||
const loopbackOrMeta =
|
||||
ip === '::1' || ip.startsWith('127.') || ip.startsWith('0.') ||
|
||||
ip.startsWith('169.254.') || /^fe[89ab][0-9a-f]:/.test(ip) || ip.startsWith('fd00:ec2:');
|
||||
if (loopbackOrMeta) throw new Error(`${what} may not point at a loopback or metadata address`);
|
||||
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) {
|
||||
throw new Error(`${what} may not point at a local address`);
|
||||
}
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) && isPrivateIp(host)) throw new Error(`${what} may not point at a private address`);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginOAuthService {
|
||||
/** The plugin's decrypted OAuth provider config from its INSTANCE settings, or null
|
||||
* when any required piece is missing/blank. */
|
||||
providerConfig(pluginId: string): OAuthProviderConfig | null {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined;
|
||||
if (!row) return null;
|
||||
let cfg: Record<string, unknown>;
|
||||
try {
|
||||
cfg = JSON.parse(row.config || '{}');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const authorizeUrl = String(cfg.oauth_authorize_url ?? '').trim();
|
||||
const tokenUrl = String(cfg.oauth_token_url ?? '').trim();
|
||||
const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : '';
|
||||
const clientSecret = cfg.oauth_client_secret ? String(decrypt_api_key(cfg.oauth_client_secret)) : '';
|
||||
const scopes = String(cfg.oauth_scopes ?? '').trim();
|
||||
if (!authorizeUrl || !tokenUrl || !clientId || !clientSecret) return null;
|
||||
return { authorizeUrl, tokenUrl, scopes, clientId, clientSecret };
|
||||
}
|
||||
|
||||
private redirectUri(pluginId: string): string {
|
||||
return `${getAppUrl()}/api/plugin-oauth/${pluginId}/callback`;
|
||||
}
|
||||
|
||||
/** Whether the acting user has a stored token for this plugin. */
|
||||
status(pluginId: string, userId: number): { configured: boolean; connected: boolean } {
|
||||
const configured = this.providerConfig(pluginId) !== null;
|
||||
const tok = db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId);
|
||||
return { configured, connected: !!tok };
|
||||
}
|
||||
|
||||
/** Begin the authorize flow: mint PKCE + state, persist them, return the provider URL. */
|
||||
startConnect(pluginId: string, userId: number, nowMs: number): string {
|
||||
const cfg = this.providerConfig(pluginId);
|
||||
if (!cfg) throw new Error('OAuth is not configured for this plugin');
|
||||
const authorize = assertSafeHttps(cfg.authorizeUrl, 'authorize_url');
|
||||
assertSafeHttps(cfg.tokenUrl, 'token_url'); // fail fast if the token endpoint is unsafe too
|
||||
|
||||
const verifier = b64url(crypto.randomBytes(32));
|
||||
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
const state = b64url(crypto.randomBytes(24));
|
||||
|
||||
// Drop this user's stale states for the plugin, then store the fresh one.
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
db.prepare('INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)').run(
|
||||
state,
|
||||
pluginId,
|
||||
userId,
|
||||
verifier,
|
||||
nowMs,
|
||||
);
|
||||
|
||||
authorize.searchParams.set('response_type', 'code');
|
||||
authorize.searchParams.set('client_id', cfg.clientId);
|
||||
authorize.searchParams.set('redirect_uri', this.redirectUri(pluginId));
|
||||
if (cfg.scopes) authorize.searchParams.set('scope', cfg.scopes);
|
||||
authorize.searchParams.set('state', state);
|
||||
authorize.searchParams.set('code_challenge', challenge);
|
||||
authorize.searchParams.set('code_challenge_method', 'S256');
|
||||
return authorize.toString();
|
||||
}
|
||||
|
||||
/** Complete the callback: verify state, exchange the code, store the tokens. */
|
||||
async completeCallback(pluginId: string, userId: number, code: string, state: string, nowMs: number): Promise<void> {
|
||||
const row = db.prepare('SELECT verifier, user_id, created_at FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?').get(state, pluginId) as
|
||||
| { verifier: string; user_id: number; created_at: number }
|
||||
| undefined;
|
||||
// State must exist, belong to THIS user, and be fresh — this binds the callback to
|
||||
// the connect request and blocks CSRF / a replayed/foreign state.
|
||||
if (!row || row.user_id !== userId || nowMs - row.created_at > STATE_TTL_MS) {
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state);
|
||||
throw new Error('invalid or expired OAuth state');
|
||||
}
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use
|
||||
|
||||
const cfg = this.providerConfig(pluginId);
|
||||
if (!cfg) throw new Error('OAuth is not configured for this plugin');
|
||||
|
||||
const token = await this.tokenRequest(cfg, {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: this.redirectUri(pluginId),
|
||||
code_verifier: row.verifier,
|
||||
});
|
||||
this.storeToken(pluginId, userId, token, nowMs);
|
||||
}
|
||||
|
||||
/** A valid access token for the acting user, refreshing it if it is expiring. Null when
|
||||
* the user hasn't connected. The plugin never receives the refresh token. */
|
||||
async getAccessToken(pluginId: string, userId: number, nowMs: number): Promise<string | null> {
|
||||
const row = db.prepare('SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
| { access_token: string | null; refresh_token: string | null; expires_at: number | null }
|
||||
| undefined;
|
||||
if (!row || !row.access_token) return null;
|
||||
|
||||
const notExpiring = row.expires_at == null || row.expires_at - REFRESH_SKEW_S * 1000 > nowMs;
|
||||
if (notExpiring) return decrypt_api_key(row.access_token) as string;
|
||||
|
||||
if (!row.refresh_token) return decrypt_api_key(row.access_token) as string; // no refresh token — hand back what we have
|
||||
const cfg = this.providerConfig(pluginId);
|
||||
if (!cfg) return null;
|
||||
const token = await this.tokenRequest(cfg, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: decrypt_api_key(row.refresh_token) as string,
|
||||
});
|
||||
// Some providers omit a new refresh_token on refresh — keep the existing one.
|
||||
if (!token.refresh_token) token.refresh_token = decrypt_api_key(row.refresh_token) as string;
|
||||
this.storeToken(pluginId, userId, token, nowMs);
|
||||
return token.access_token ?? null;
|
||||
}
|
||||
|
||||
disconnect(pluginId: string, userId: number): void {
|
||||
db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
private async tokenRequest(cfg: OAuthProviderConfig, params: Record<string, string>): Promise<{ access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }> {
|
||||
assertSafeHttps(cfg.tokenUrl, 'token_url');
|
||||
const body = new URLSearchParams({ ...params, client_id: cfg.clientId, client_secret: cfg.clientSecret });
|
||||
// Route the server-side token POST through the SSRF guard: it resolves the host
|
||||
// and refuses the link-local / cloud-metadata range (169.254/fe80/IMDSv6) while
|
||||
// pinning the connection to the resolved IP, so a token_url that is a DNS name
|
||||
// (or IPv6 literal) pointing at metadata can't reach it and can't DNS-rebind.
|
||||
// Loopback/LAN stay reachable so a self-hosted internal IdP keeps working.
|
||||
const resp = await safeFetchLlm(cfg.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`token endpoint returned ${resp.status}`);
|
||||
const json = (await resp.json()) as Record<string, unknown>;
|
||||
return {
|
||||
access_token: typeof json.access_token === 'string' ? json.access_token : undefined,
|
||||
refresh_token: typeof json.refresh_token === 'string' ? json.refresh_token : undefined,
|
||||
expires_in: typeof json.expires_in === 'number' ? json.expires_in : undefined,
|
||||
scope: typeof json.scope === 'string' ? json.scope : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): void {
|
||||
if (!token.access_token) throw new Error('token endpoint returned no access_token');
|
||||
const expiresAt = token.expires_in ? nowMs + token.expires_in * 1000 : null;
|
||||
db.prepare(
|
||||
`INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id) DO UPDATE SET
|
||||
access_token = excluded.access_token,
|
||||
refresh_token = COALESCE(excluded.refresh_token, plugin_oauth_tokens.refresh_token),
|
||||
expires_at = excluded.expires_at,
|
||||
scope = excluded.scope,
|
||||
updated_at = excluded.updated_at`,
|
||||
).run(
|
||||
pluginId,
|
||||
userId,
|
||||
encrypt_api_key(token.access_token),
|
||||
token.refresh_token ? encrypt_api_key(token.refresh_token) : null,
|
||||
expiresAt,
|
||||
token.scope ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* Photo sources contributed by plugins that implement the `photoProvider` hook
|
||||
* (needs `hook:photo-provider`). This is the core consumer that makes the hook LIVE:
|
||||
* GET /api/plugin-photos/sources — the installed providers (id + name)
|
||||
* GET /api/plugin-photos/search?q=&page= — aggregate search across providers
|
||||
* GET /api/plugin-photos/item?pluginId=&id= — one photo by id from one provider
|
||||
* The picker fans these into its "plugin sources" tab. Additive + fail-safe: each
|
||||
* provider is called host→plugin on a short timeout, one that errors/times out is
|
||||
* skipped. Every field is NORMALIZED: strings length-capped, the photo count capped,
|
||||
* and thumbnail/full URLs must be http/https (an image src) — never javascript:/data:.
|
||||
*/
|
||||
interface DevPhoto { id: string; pluginId: string; title?: string; thumbnailUrl: string; fullUrl: string; takenAt?: string; }
|
||||
|
||||
const MAX_PHOTOS = 60; // per provider per page
|
||||
const cap = (v: unknown, n: number): string => String(v ?? '').slice(0, n);
|
||||
|
||||
/** http/https only — these become <img src>, so a javascript:/data: URL is XSS. */
|
||||
function imageUrl(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw === '' || raw.length > 4096) return undefined;
|
||||
try {
|
||||
const p = new URL(raw).protocol;
|
||||
return p === 'http:' || p === 'https:' ? raw : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePhotos(pluginId: string, raw: unknown): DevPhoto[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: DevPhoto[] = [];
|
||||
for (const p of list) {
|
||||
if (out.length >= MAX_PHOTOS) break;
|
||||
if (!p || typeof p !== 'object') continue;
|
||||
const id = cap(p.id, 256);
|
||||
const thumb = imageUrl(p.thumbnailUrl);
|
||||
const full = imageUrl(p.fullUrl);
|
||||
if (!id || !thumb || !full) continue; // an unusable photo (no id / bad urls) is dropped
|
||||
out.push({
|
||||
id, pluginId,
|
||||
// Strip emojis from the display title (but NOT the id — that round-trips to getById).
|
||||
title: p.title != null ? stripEmoji(cap(p.title, 200)) : undefined,
|
||||
thumbnailUrl: thumb,
|
||||
fullUrl: full,
|
||||
takenAt: typeof p.takenAt === 'string' ? cap(p.takenAt, 40) : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/plugin-photos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginPhotosController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get('sources')
|
||||
sources(): { sources: Array<{ pluginId: string }> } {
|
||||
if (!pluginsEnabled()) return { sources: [] };
|
||||
return { sources: this.runtime.providersOf('photoProvider').map((pluginId) => ({ pluginId })) };
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
async search(
|
||||
@Query('q') q: string | undefined,
|
||||
@Query('page') pageRaw: string | undefined,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ providers: Array<{ pluginId: string; photos: DevPhoto[]; total: number; hasMore: boolean }> }> {
|
||||
if (!pluginsEnabled()) return { providers: [] };
|
||||
const userId = req.user?.id;
|
||||
if (userId == null) return { providers: [] };
|
||||
const query = cap(q, 200);
|
||||
const page = Math.max(1, Math.min(1000, Number(pageRaw) || 1));
|
||||
|
||||
const ids = this.runtime.providersOf('photoProvider');
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
const raw = (await this.runtime.invokeHook(id, 'photoProvider', 'search', [query, { page, limit: MAX_PHOTOS }], userId, 5000)) as
|
||||
| { photos?: unknown; total?: unknown; hasMore?: unknown }
|
||||
| undefined;
|
||||
const photos = normalizePhotos(id, raw?.photos);
|
||||
if (photos.length === 0) return null;
|
||||
return { pluginId: id, photos, total: Number(raw?.total) || photos.length, hasMore: raw?.hasMore === true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { providers: results.filter((r): r is { pluginId: string; photos: DevPhoto[]; total: number; hasMore: boolean } => r !== null) };
|
||||
}
|
||||
|
||||
@Get('item')
|
||||
async item(
|
||||
@Query('pluginId') pluginId: string | undefined,
|
||||
@Query('id') id: string | undefined,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ photo: DevPhoto | null }> {
|
||||
if (!pluginsEnabled()) return { photo: null };
|
||||
const userId = req.user?.id;
|
||||
if (userId == null || !pluginId || !id) return { photo: null };
|
||||
if (!this.runtime.providersOf('photoProvider').includes(pluginId)) return { photo: null };
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(pluginId, 'photoProvider', 'getById', [cap(id, 256)], userId, 5000);
|
||||
return { photo: normalizePhotos(pluginId, [raw])[0] ?? null };
|
||||
} catch {
|
||||
return { photo: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,20 @@ import semver from 'semver';
|
||||
import { db } from '../../db/database';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { setPluginEventSink } from '../../plugin-event-sink';
|
||||
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
|
||||
import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup';
|
||||
import { decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host';
|
||||
import { ForbiddenResource } from './host/rpc-host';
|
||||
import { removePluginData } from './host/plugin-data.service';
|
||||
import { isKnownPermission } from './protocol/envelope';
|
||||
import { discoverPlugins } from './install/discovery';
|
||||
import { parseJsonText, parseManifest } from './install/manifest';
|
||||
import { scanForNativeBinaries } from './install/native-scan';
|
||||
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
|
||||
import { pluginCodeDir } from './paths';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
@@ -20,6 +26,37 @@ import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enab
|
||||
|
||||
const HTTP_OUTBOUND = 'http:outbound:';
|
||||
|
||||
/**
|
||||
* Remove `<plugins>/<id>` whether it is a real directory, a POSIX symlink or a
|
||||
* Windows junction — WITHOUT ever following a dev-link into (and deleting) the
|
||||
* author's source. A symlink is unlinked; a junction (which lstats as a directory
|
||||
* on Windows) is rmdir'd (drops the reparse point, not the target); a real dir is
|
||||
* recursively removed. A no-op if nothing is there.
|
||||
*/
|
||||
function removePluginCodeEntry(dest: string): void {
|
||||
let lst: fs.Stats;
|
||||
try {
|
||||
lst = fs.lstatSync(dest);
|
||||
} catch {
|
||||
return; // nothing to remove
|
||||
}
|
||||
if (lst.isSymbolicLink()) {
|
||||
fs.unlinkSync(dest); // POSIX symlink -> drop the link only
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'win32' && lst.isDirectory()) {
|
||||
// A junction lstats as a directory; rmdir removes the junction itself, not the
|
||||
// target. A REAL non-empty dir throws ENOTEMPTY -> fall through to a full remove.
|
||||
try {
|
||||
fs.rmdirSync(dest);
|
||||
return;
|
||||
} catch {
|
||||
/* real, non-empty directory */
|
||||
}
|
||||
}
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Thrown when (re-)activating would grant permissions the admin hasn't consented to. */
|
||||
export class PluginConsentRequired extends Error {
|
||||
constructor(message: string, readonly newPermissions: string[] = [], readonly newEgress: string[] = []) {
|
||||
@@ -59,29 +96,64 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// plugin's ctx.plugins.call / ctx.events.emit resolve through callPlugin/
|
||||
// emitPluginEvent below (which own the dependency-edge authorization).
|
||||
private readonly supervisor = new PluginSupervisor((id, granted) => createRealRpcHost(id, granted, this), {
|
||||
// Both hooks run from child lifecycle EventEmitter callbacks (exit / stderr 'data'),
|
||||
// so a throw here becomes an uncaughtException that has no host-side handler. During a
|
||||
// restore the core DB is briefly CLOSED (closeDb → the db proxy throws on access), so a
|
||||
// status/log write in that window would otherwise take the whole process down mid-
|
||||
// restore. Swallow any DB error — a missed status row / log line is never worth a crash.
|
||||
onStatus: (id, status, error) => {
|
||||
db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
status,
|
||||
error ?? null,
|
||||
id,
|
||||
);
|
||||
try {
|
||||
db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id);
|
||||
} catch { /* DB unavailable (e.g. mid-restore) — a status write must never crash the host */ }
|
||||
},
|
||||
onLog: (id, level, msg) => {
|
||||
if (level === 'error' || level === 'warn') {
|
||||
if (level !== 'error' && level !== 'warn') return;
|
||||
try {
|
||||
db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg);
|
||||
}
|
||||
// Retention: a crash-looping plugin emits a stderr line per restart, so an
|
||||
// uncapped table grows without bound in the shared trek.db. Keep only the
|
||||
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
|
||||
pruneErrorLog(id);
|
||||
} catch { /* DB unavailable — a log line must never crash the host */ }
|
||||
},
|
||||
});
|
||||
|
||||
// Filesystem watchers for dev-linked plugins (id -> watcher), so a rebuild of the
|
||||
// author's source auto-reloads. Empty unless dev-link is used.
|
||||
private readonly linkWatchers = new Map<string, fs.FSWatcher>();
|
||||
|
||||
// Sweeps plugin_scheduled_tasks for due callbacks and fires them on active plugins.
|
||||
private schedulerSweep: ReturnType<typeof setInterval> | null = null;
|
||||
// Coalesces overlapping erasure drains (the sweep and enqueue both trigger one).
|
||||
private drainInFlight: Promise<void> | null = null;
|
||||
|
||||
// Optional at the type level so tests can `new PluginRuntimeService()` without a
|
||||
// registry; Nest always injects the real one (the provider is in the module).
|
||||
constructor(private readonly registry?: PluginRegistryService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!pluginsEnabled()) return;
|
||||
// If a restore staged plugin trees, swap them into place NOW — before we open any
|
||||
// plugin DB below. This is where a restored backup's plugin data/code actually
|
||||
// takes effect (the restore itself only stages, since the runtime holds the DBs
|
||||
// open). No-op when nothing was staged. Defensive: never blocks boot.
|
||||
try {
|
||||
const applied = applyStagedPluginTrees();
|
||||
if (applied.length) console.log(`[plugins] applied staged restore: ${applied.join(', ')}`);
|
||||
} catch { /* reconcile must never stop the server from starting */ }
|
||||
// Let a live restore apply its staged plugin trees IMMEDIATELY instead of leaving
|
||||
// them for an arbitrary future boot: quiesce every plugin (closing its DB handles)
|
||||
// then swap. Plugins stay down until the app restart the restore already requires.
|
||||
setStagedRestoreApplier(async () => {
|
||||
await this.supervisor.shutdownAll();
|
||||
applyStagedPluginTrees();
|
||||
});
|
||||
// Forward core trip events to plugins that subscribed (events:subscribe). The
|
||||
// sink is name-only + fire-and-forget, so it can never block a core broadcast.
|
||||
setPluginEventSink((tripId, event) => this.supervisor.deliverEvent(tripId, event));
|
||||
setPluginEventSink((tripId, event, meta) => this.supervisor.deliverEvent(tripId, event, meta));
|
||||
// Fan a deleted account out to plugins so they can erase their own per-user data.
|
||||
// Enqueued durably (survives restart), so nothing is lost if a plugin is offline.
|
||||
setUserDeletedSink((userId) => this.enqueueUserErasure(userId));
|
||||
// Discover plugins placed on the volume (registers new ones inactive), then
|
||||
// boot the ones an admin had already activated — in dependency order so a
|
||||
// plugin's dependencies come up before it does. The whole block is defensive:
|
||||
@@ -112,6 +184,158 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
} catch {
|
||||
/* discovery/boot must never block app init */
|
||||
}
|
||||
// Fire due scheduled tasks (persistent, userless) on a coarse tick — the
|
||||
// scheduler is minute-granularity by contract, so 30s precision is plenty and
|
||||
// cheap. Unref'd so it never holds the process open.
|
||||
this.schedulerSweep = setInterval(() => {
|
||||
this.fireDueScheduled();
|
||||
void this.drainUserErasures();
|
||||
}, 30_000);
|
||||
this.schedulerSweep.unref?.();
|
||||
}
|
||||
|
||||
/** Fire every scheduled task that is due on an ACTIVE plugin; re-arm recurring
|
||||
* ones, delete one-shots. The row is re-armed/deleted BEFORE the fire so a crash
|
||||
* mid-callback can't double-fire; an inactive plugin's tasks are left untouched so
|
||||
* they run on the next sweep after it reactivates. Never throws. */
|
||||
private fireDueScheduled(): void {
|
||||
if (!pluginsEnabled()) return;
|
||||
try {
|
||||
// Scope the window to ACTIVE plugins so a backlog of past-due rows belonging to
|
||||
// inactive plugins can't fill the LIMIT and starve active plugins' timers.
|
||||
const active = this.supervisor.activeIds();
|
||||
if (active.length === 0) return;
|
||||
const now = Date.now();
|
||||
const ph = active.map(() => '?').join(',');
|
||||
const due = db
|
||||
.prepare(`SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200`)
|
||||
.all(now, ...active) as Array<{ id: number; plugin_id: string; name: string; payload: string; every_ms: number | null }>;
|
||||
for (const t of due) {
|
||||
if (!this.supervisor.isActive(t.plugin_id)) continue; // leave for a later sweep
|
||||
if (t.every_ms) db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id);
|
||||
else db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id);
|
||||
let payload: unknown = null;
|
||||
try { payload = JSON.parse(t.payload); } catch { /* corrupt payload -> null */ }
|
||||
this.supervisor.deliverScheduled(t.plugin_id, t.name, payload);
|
||||
}
|
||||
} catch {
|
||||
/* a sweep must never break the runtime */
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue a GDPR erasure for every installed plugin that holds hook:user-data, then
|
||||
* try to deliver immediately. Persisted first (INSERT OR IGNORE, idempotent) so the
|
||||
* erasure survives a restart and reaches a plugin that is offline right now. Never
|
||||
* throws — a bookkeeping error must not fail the account deletion that triggered it. */
|
||||
private enqueueUserErasure(userId: number): void {
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
|
||||
for (const r of rows) {
|
||||
let perms: unknown;
|
||||
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
|
||||
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
|
||||
}
|
||||
} catch {
|
||||
/* enqueue is best-effort; a later sweep reconciles from whatever landed */
|
||||
}
|
||||
void this.drainUserErasures();
|
||||
}
|
||||
|
||||
/** Deliver queued erasures to active plugins, dropping each row only once the plugin
|
||||
* ACKs. Rows for inactive plugins are left for a later sweep / their reactivation.
|
||||
* Never throws. */
|
||||
private drainUserErasures(): Promise<void> {
|
||||
// Coalesce onto the drain already in flight. Both the 30s sweep and enqueue trigger
|
||||
// a drain, and a pass awaits per-row delivery (up to the invoke timeout each), so
|
||||
// running two concurrently would select the SAME rows and deliver an erasure twice.
|
||||
// A caller that awaits still waits for a full pass (the in-flight one).
|
||||
if (this.drainInFlight) return this.drainInFlight;
|
||||
this.drainInFlight = this.runDrainOnce().finally(() => { this.drainInFlight = null; });
|
||||
return this.drainInFlight;
|
||||
}
|
||||
|
||||
private async runDrainOnce(): Promise<void> {
|
||||
if (!pluginsEnabled()) return;
|
||||
try {
|
||||
// Reap rows whose plugin no longer exists (uninstalled): its data dir was removed
|
||||
// with it, so the erasure is already satisfied, and the plugin will never reactivate
|
||||
// to ACK the row — left alone it would linger in the queue forever.
|
||||
db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id NOT IN (SELECT id FROM plugins)').run();
|
||||
// Only ACTIVE plugins can be delivered to; scope the window to them so a backlog
|
||||
// of erasures for permanently-inactive plugins can't starve deliverable ones.
|
||||
const active = this.supervisor.activeIds();
|
||||
if (active.length === 0) return;
|
||||
const ph = active.map(() => '?').join(',');
|
||||
const pending = db
|
||||
.prepare(`SELECT id, plugin_id, user_id FROM plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200`)
|
||||
.all(...active) as Array<{ id: number; plugin_id: string; user_id: number }>;
|
||||
for (const row of pending) {
|
||||
if (!this.supervisor.isActive(row.plugin_id)) continue; // retry after it reactivates
|
||||
const done = await this.supervisor.deliverUserErasure(row.plugin_id, row.user_id);
|
||||
if (done) db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id);
|
||||
}
|
||||
} catch {
|
||||
/* a drain pass must never break the runtime */
|
||||
}
|
||||
}
|
||||
|
||||
/** GDPR portability: aggregate what every granted plugin holds about a user. An active
|
||||
* plugin whose export ERRORED and an inactive granted plugin are both flagged `pending`
|
||||
* (rather than silently omitted), so the export never reads as complete while missing data. */
|
||||
async exportUserData(userId: number): Promise<Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }>> {
|
||||
const out: Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }> = [];
|
||||
if (!pluginsEnabled()) return out;
|
||||
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
for (const r of rows) {
|
||||
if (this.supervisor.isActive(r.id)) {
|
||||
const res = await this.supervisor.collectUserExport(r.id, userId);
|
||||
if (res === undefined) continue; // not granted → nothing to export
|
||||
if (res.ok) out.push({ pluginId: r.id, data: res.data });
|
||||
else out.push({ pluginId: r.id, pending: true }); // errored/timed out → incomplete, retryable
|
||||
} else {
|
||||
// An inactive plugin can't export now — but if it holds hook:user-data it MAY
|
||||
// hold this user's data. Flag it as pending (rather than silently omitting it)
|
||||
// so the admin knows to reactivate it to complete a data-access request.
|
||||
let perms: unknown;
|
||||
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
|
||||
if (Array.isArray(perms) && perms.includes('hook:user-data')) out.push({ pluginId: r.id, pending: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Fold in the host-side per-user data TREK stores itself (what erasePluginUserData
|
||||
// deletes) so an access request isn't asymmetric with erasure: the user's plugin
|
||||
// settings (secret fields masked) and which plugins they OAuth-linked. Raw tokens
|
||||
// are never exported. This is supplementary to each plugin's own-db export above, so
|
||||
// an unexpected failure here must not drop that primary data — it's best-effort.
|
||||
try {
|
||||
const byId = new Map(out.map((o) => [o.pluginId, o]));
|
||||
const entryFor = (pluginId: string) => {
|
||||
let e = byId.get(pluginId);
|
||||
if (!e) { e = { pluginId }; out.push(e); byId.set(pluginId, e); }
|
||||
return e;
|
||||
};
|
||||
const secretKeys = new Map<string, Set<string>>();
|
||||
for (const f of db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) {
|
||||
let s = secretKeys.get(f.plugin_id);
|
||||
if (!s) { s = new Set(); secretKeys.set(f.plugin_id, s); }
|
||||
s.add(f.field_key);
|
||||
}
|
||||
for (const c of db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) {
|
||||
let cfg: Record<string, unknown> = {};
|
||||
try { cfg = JSON.parse(c.config || '{}'); } catch { /* ignore */ }
|
||||
const secrets = secretKeys.get(c.plugin_id) ?? new Set<string>();
|
||||
const masked: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(cfg)) masked[k] = secrets.has(k) ? '***' : v;
|
||||
entryFor(c.plugin_id).settings = masked;
|
||||
}
|
||||
for (const t of db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) {
|
||||
entryFor(t.plugin_id).oauthConnected = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[plugins] GDPR export: host-side settings/oauth fold failed', err);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +364,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
setPluginEventSink(null);
|
||||
setUserDeletedSink(null);
|
||||
setStagedRestoreApplier(null);
|
||||
if (this.schedulerSweep) { clearInterval(this.schedulerSweep); this.schedulerSweep = null; }
|
||||
for (const w of this.linkWatchers.values()) {
|
||||
try { w.close(); } catch { /* ignore */ }
|
||||
}
|
||||
this.linkWatchers.clear();
|
||||
await this.supervisor.shutdownAll();
|
||||
}
|
||||
|
||||
@@ -326,19 +557,124 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-ONLY: register a plugin from a LOCAL built directory and hot-reload it
|
||||
* against this instance's REAL data. Symlinks `<plugins>/<id>` at the author's
|
||||
* dir (so the supervisor forks the local code with ZERO loader change), validates
|
||||
* the manifest + refuses native binaries like a sideload, registers it INACTIVE,
|
||||
* and starts an fs.watch that re-forks on rebuild. Gated behind TREK_PLUGINS_DEV_LINK
|
||||
* on top of the controller's admin + kill-switch gates — see dev-link.ts for why.
|
||||
*/
|
||||
async link(sourceDir: string): Promise<{ id: string; version: string; replaced: boolean }> {
|
||||
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
|
||||
if (!path.isAbsolute(sourceDir)) throw new Error('the dev-link path must be absolute');
|
||||
const manifestPath = path.join(sourceDir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json at ${sourceDir}`);
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
|
||||
if (!fs.existsSync(path.join(sourceDir, 'server', 'index.js'))) {
|
||||
throw new Error('no built server/index.js — build the plugin first (the loader runs the compiled artifact, not TS source)');
|
||||
}
|
||||
if (scanForNativeBinaries(sourceDir).length) throw new Error('directory contains native binaries');
|
||||
|
||||
const id = manifest.id;
|
||||
const existing = db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
// Never clobber a REAL installed plugin (registry/sideload) — only re-point a link.
|
||||
if (existing && existing.source_repo !== DEV_LINK_SOURCE) {
|
||||
throw new Error(`a plugin '${id}' is already installed — uninstall it before dev-linking that id`);
|
||||
}
|
||||
|
||||
const dest = pluginCodeDir(id);
|
||||
const replaced = !!existing;
|
||||
if (replaced) await this.deactivate(id); // stop the child (stale code / file locks) before re-pointing
|
||||
this.stopWatch(id);
|
||||
removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
|
||||
discoverPlugins(db); // registers/updates the row from the linked manifest, INACTIVE
|
||||
db.prepare(
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?`,
|
||||
).run(DEV_LINK_SOURCE, id);
|
||||
this.watchLinked(id, sourceDir);
|
||||
return { id, version: manifest.version, replaced };
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-ONLY: re-fork a dev-linked plugin so it picks up freshly-built code. This is
|
||||
* the same deactivate->activate primitive the supervisor uses; the acting-user +
|
||||
* capability gates are unchanged, so it keeps running against real, membership-gated
|
||||
* data. Only re-activates if it was active (preserving the admin's on/off intent);
|
||||
* a manifest that widened its permissions still requires explicit re-consent.
|
||||
*/
|
||||
async reload(id: string): Promise<void> {
|
||||
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
|
||||
const row = db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
if (row.source_repo !== DEV_LINK_SOURCE) throw new Error(`plugin ${id} is not dev-linked`);
|
||||
const wasActive = this.isActive(id);
|
||||
await this.deactivate(id);
|
||||
if (wasActive) await this.activate(id);
|
||||
}
|
||||
|
||||
/** Best-effort fs.watch on a linked plugin's built output that debounces -> reload. */
|
||||
private watchLinked(id: string, sourceDir: string): void {
|
||||
this.stopWatch(id);
|
||||
const serverDir = path.join(sourceDir, 'server'); // the loader runs server/index.js
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const watcher = fs.watch(serverDir, { recursive: true }, () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (this.isActive(id)) void this.reload(id).catch(() => {}); // only re-fork a running plugin
|
||||
}, 400); // debounce a rebuild's write burst into one re-fork
|
||||
timer.unref?.();
|
||||
});
|
||||
watcher.on('error', () => this.stopWatch(id));
|
||||
this.linkWatchers.set(id, watcher);
|
||||
} catch {
|
||||
/* fs.watch(recursive) is best-effort; POST /:id/reload still works */
|
||||
}
|
||||
}
|
||||
|
||||
private stopWatch(id: string): void {
|
||||
const w = this.linkWatchers.get(id);
|
||||
if (w) {
|
||||
try { w.close(); } catch { /* ignore */ }
|
||||
this.linkWatchers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the plugin, remove its code, and optionally delete all its data. */
|
||||
async uninstall(id: string, deleteData: boolean): Promise<void> {
|
||||
await this.supervisor.disable(id);
|
||||
this.stopWatch(id);
|
||||
closePluginDataDb(id);
|
||||
// Code always goes; the DB metadata + fields go so it disappears from the UI.
|
||||
fs.rmSync(pluginCodeDir(id), { recursive: true, force: true });
|
||||
// Link-safe: a dev-linked plugin only drops the symlink, never the author's source.
|
||||
removePluginCodeEntry(pluginCodeDir(id));
|
||||
db.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id);
|
||||
// Scheduled tasks are operational (not user data), so they go unconditionally —
|
||||
// a scheduled callback for a plugin that no longer exists must never fire.
|
||||
db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id);
|
||||
if (deleteData) {
|
||||
removePluginData(id);
|
||||
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`);
|
||||
db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id);
|
||||
// Per-user secrets + OAuth tokens/state live in their own tables, NOT under
|
||||
// settings — without these a "delete all data" leaves encrypted API keys and
|
||||
// refresh tokens behind, silently re-adopted if a plugin with the same id is
|
||||
// reinstalled. The migration ledger goes too, so a reinstall re-runs cleanly.
|
||||
db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id);
|
||||
// The plugin's data dir is gone now, so any pending GDPR erasure for it is moot.
|
||||
// But when deleteData is FALSE we deliberately KEEP the queue rows: the data dir
|
||||
// (which may still hold a deleted user's rows) survives, so the erasure obligation
|
||||
// must survive too — a reinstall of the same id drains the queue and honours it.
|
||||
db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,3 +808,14 @@ function decryptConfig(config: Record<string, unknown>): Record<string, unknown>
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LOG_RETENTION = 500; // rows kept per plugin (the admin view shows the newest 200)
|
||||
/** Trim a plugin's error log to the most recent LOG_RETENTION rows. Cheap: onLog
|
||||
* only fires on warn/error, so this never runs on the hot path. */
|
||||
function pruneErrorLog(pluginId: string): void {
|
||||
db.prepare(
|
||||
`DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN (
|
||||
SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${LOG_RETENTION}
|
||||
)`,
|
||||
).run(pluginId, pluginId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
/**
|
||||
* GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a
|
||||
* plugin (#plugins). Deliberately its own path (not under the admin surface, not
|
||||
* under the `/api/plugins/:id/*` proxy) and gated by JwtAuthGuard only: every user
|
||||
* manages their OWN config here — an API key, a personal preference — separate from
|
||||
* the admin-owned instance config.
|
||||
*
|
||||
* Secrets are stored encrypted and NEVER echoed back (masked); the write only accepts
|
||||
* keys the plugin declared as `scope:'user'` fields. The plugin reads the acting
|
||||
* user's value at runtime via `ctx.settings.get(key)`.
|
||||
*/
|
||||
@Controller('api/plugin-settings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginUserSettingsController {
|
||||
constructor(private readonly plugins: PluginsService) {}
|
||||
|
||||
private activeWithUserFields(id: string): boolean {
|
||||
const row = db.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { fields: unknown[]; config: Record<string, unknown> } {
|
||||
const userId = req.user?.id;
|
||||
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {} };
|
||||
return { fields: this.plugins.userSettingsFields(id), config: this.plugins.getUserConfig(id, userId) };
|
||||
}
|
||||
|
||||
@Post(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { config?: Record<string, unknown> },
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): { config: Record<string, unknown> } {
|
||||
const userId = req.user?.id;
|
||||
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} };
|
||||
const patch = body?.config && typeof body.config === 'object' ? body.config : {};
|
||||
return { config: this.plugins.updateUserConfig(id, userId, patch) };
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ interface ActivePlugin {
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
slot: 'sidebar' | 'hero' | 'place-detail';
|
||||
slot: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail';
|
||||
/** How a trip-page plugin sits in the planner tab bar (replaced core tabs + position). */
|
||||
tripPage?: { replaces?: string[]; position?: number };
|
||||
}
|
||||
|
||||
@Controller('api/plugins')
|
||||
@@ -26,18 +28,40 @@ export class PluginsFeedController {
|
||||
if (!pluginsEnabled()) return { plugins: [] };
|
||||
const rows = db
|
||||
.prepare("SELECT id, name, type, icon, capabilities FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
|
||||
.all() as Array<Omit<ActivePlugin, 'slot'> & { capabilities: string }>;
|
||||
const plugins = rows.map(({ capabilities, ...p }) => ({ ...p, slot: slotOf(capabilities) }));
|
||||
.all() as Array<Omit<ActivePlugin, 'slot' | 'tripPage'> & { capabilities: string }>;
|
||||
const plugins = rows.map(({ capabilities, ...p }) => {
|
||||
const tripPage = p.type === 'trip-page' ? tripPageOf(capabilities) : undefined;
|
||||
return { ...p, slot: slotOf(capabilities), ...(tripPage ? { tripPage } : {}) };
|
||||
});
|
||||
return { plugins };
|
||||
}
|
||||
}
|
||||
|
||||
function slotOf(capabilities: string): 'sidebar' | 'hero' | 'place-detail' {
|
||||
function slotOf(capabilities: string): ActivePlugin['slot'] {
|
||||
try {
|
||||
const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } };
|
||||
const slot = c.widget?.slot;
|
||||
return slot === 'hero' || slot === 'place-detail' ? slot : 'sidebar';
|
||||
return slot === 'hero' || slot === 'place-detail' || slot === 'day-detail' || slot === 'reservation-detail' ? slot : 'sidebar';
|
||||
} catch {
|
||||
return 'sidebar';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validated here even though the manifest parser already gated the values —
|
||||
// the capabilities column is a JSON blob, and the tab list the client hides
|
||||
// must never be steerable by a hand-edited row ('plan' stays unhideable).
|
||||
const REPLACEABLE_TABS: ReadonlySet<string> = new Set(['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab']);
|
||||
|
||||
function tripPageOf(capabilities: string): ActivePlugin['tripPage'] {
|
||||
try {
|
||||
const c = JSON.parse(capabilities || '{}') as { tripPage?: { replaces?: unknown; position?: unknown } };
|
||||
const tp = c.tripPage;
|
||||
if (!tp || typeof tp !== 'object') return undefined;
|
||||
const replaces = Array.isArray(tp.replaces) ? tp.replaces.filter((t): t is string => typeof t === 'string' && REPLACEABLE_TABS.has(t)) : [];
|
||||
const position = typeof tp.position === 'number' && Number.isInteger(tp.position) && tp.position >= 0 && tp.position <= 50 ? tp.position : undefined;
|
||||
if (!replaces.length && position === undefined) return undefined;
|
||||
return { ...(replaces.length ? { replaces } : {}), ...(position !== undefined ? { position } : {}) };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,30 @@ import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
// consumers (the plugin's own client) are unaffected — fetch ignores both.
|
||||
const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control']);
|
||||
|
||||
// Inbound header allowlist for `auth:false` routes (webhooks). A plugin can verify a
|
||||
// provider's signature, but ONLY over an explicit allowlist that NEVER carries an
|
||||
// auth/session credential — mirroring the response-header allowlist above. Cookie,
|
||||
// Authorization, X-Socket-Id and every forwarded-auth header are deliberately absent,
|
||||
// so a forwarded header can never leak a TREK session or be replayed. Signature +
|
||||
// event headers from the common providers (GitHub/Stripe/Svix/GitLab/generic) pass.
|
||||
const SAFE_INBOUND_HEADERS = new Set([
|
||||
'content-type', 'user-agent', 'x-request-id', 'x-idempotency-key',
|
||||
'x-hub-signature', 'x-hub-signature-256', 'x-github-event', 'x-github-delivery',
|
||||
'stripe-signature',
|
||||
'svix-id', 'svix-timestamp', 'svix-signature',
|
||||
'x-gitlab-event', 'x-gitlab-token',
|
||||
'x-signature', 'x-signature-256', 'x-webhook-signature', 'x-event-type',
|
||||
]);
|
||||
|
||||
function pickInboundHeaders(raw: Record<string, unknown> | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(raw ?? {})) {
|
||||
if (!SAFE_INBOUND_HEADERS.has(k.toLowerCase())) continue;
|
||||
out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Origin used only to parse a plugin-supplied redirect target. Any host works as
|
||||
// long as it can never match a real one, so a target that stays "same-origin"
|
||||
// after WHATWG URL parsing is provably a relative in-app path.
|
||||
@@ -88,6 +112,15 @@ export class PluginsProxyController {
|
||||
path: sub,
|
||||
query: req.query,
|
||||
body: req.body ?? null,
|
||||
// Webhook (auth:false) routes also receive the raw request bytes (base64,
|
||||
// so a non-UTF-8 signed body survives) — the plugin decodes and verifies a
|
||||
// provider's HMAC over the exact payload; the parsed JSON can't be
|
||||
// re-serialised identically.
|
||||
rawBodyBase64: route.auth === false ? ((req as { rawBody?: Buffer }).rawBody?.toString('base64') ?? null) : undefined,
|
||||
// Only auth:false routes (webhooks) get inbound headers, and only the
|
||||
// allowlisted, credential-free subset — an authenticated route never
|
||||
// needs them and must not see even the safe ones.
|
||||
headers: route.auth === false ? pickInboundHeaders(req.headers as Record<string, unknown>) : {},
|
||||
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PluginRegistryService } from './registry/registry.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
|
||||
/**
|
||||
* /api/admin/plugins — admin-only plugin control surface (#plugins).
|
||||
@@ -74,6 +75,24 @@ export class PluginsController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-ONLY: register a plugin from a LOCAL built directory and hot-reload it
|
||||
* against real data. Gated by TREK_PLUGINS_DEV_LINK on top of admin + kill-switch.
|
||||
*/
|
||||
@Post('link')
|
||||
@HttpCode(200)
|
||||
async link(@Body() body: { path?: string }) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403);
|
||||
const dir = body?.path?.trim();
|
||||
if (!dir) throw new HttpException({ error: 'path is required' }, 400);
|
||||
try {
|
||||
return await this.runtime.link(dir);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'link failed' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/config')
|
||||
getConfig(@Param('id') id: string) {
|
||||
return { config: this.plugins.getInstanceConfig(id) };
|
||||
@@ -118,6 +137,28 @@ export class PluginsController {
|
||||
return { status: 'inactive' };
|
||||
}
|
||||
|
||||
/** DEV-ONLY: re-fork a dev-linked plugin so it picks up rebuilt code. */
|
||||
@Post(':id/reload')
|
||||
@HttpCode(200)
|
||||
async reload(@Param('id') id: string) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403);
|
||||
try {
|
||||
await this.runtime.reload(id);
|
||||
} catch (e) {
|
||||
// A rebuilt manifest that widened permissions must still re-consent, exactly
|
||||
// like activate — surface the same codes so the admin UI reacts identically.
|
||||
if (e instanceof PluginConsentRequired) {
|
||||
throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409);
|
||||
}
|
||||
if (e instanceof PluginDependencyError) {
|
||||
throw new HttpException({ error: e.message, code: e.code, ...e.detail }, 409);
|
||||
}
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'reload failed' }, 400);
|
||||
}
|
||||
return { status: this.runtime.isActive(id) ? 'active' : 'inactive' };
|
||||
}
|
||||
|
||||
@Post(':id/update')
|
||||
@HttpCode(200)
|
||||
async update(@Param('id') id: string) {
|
||||
@@ -146,6 +187,21 @@ export class PluginsController {
|
||||
return { audit: this.plugins.auditLog(id) };
|
||||
}
|
||||
|
||||
@Get(':id/budget')
|
||||
budget(@Param('id') id: string) {
|
||||
return { budget: this.plugins.budget(id) };
|
||||
}
|
||||
|
||||
/** GDPR portability: aggregate everything the installed plugins hold about one
|
||||
* user, for an admin fulfilling a data-access request. Literal-prefixed path, so it
|
||||
* never collides with the :id routes. */
|
||||
@Get('user-data/:userId/export')
|
||||
async exportUserData(@Param('userId') userId: string) {
|
||||
const id = Number(userId);
|
||||
if (!Number.isInteger(id) || id <= 0) throw new HttpException({ error: 'invalid user id' }, 400);
|
||||
return { userId: id, plugins: await this.runtime.exportUserData(id) };
|
||||
}
|
||||
|
||||
@Delete(':id/errors')
|
||||
clearErrors(@Param('id') id: string) {
|
||||
this.plugins.clearErrors(id);
|
||||
|
||||
@@ -5,6 +5,18 @@ import { PluginsProxyController } from './plugins-proxy.controller';
|
||||
import { PluginFrameController } from './plugin-frame.controller';
|
||||
import { PlaceDetailsController } from './place-details.controller';
|
||||
import { TripWarningsController } from './trip-warnings.controller';
|
||||
import { ViewContributionsController } from './view-contributions.controller';
|
||||
import { TripCardContributionsController } from './trip-card-contributions.controller';
|
||||
import { PluginPhotosController } from './plugin-photos.controller';
|
||||
import { PluginCalendarController } from './plugin-calendar.controller';
|
||||
import { MapMarkersController } from './map-markers.controller';
|
||||
import { PluginActivityController } from './plugin-activity.controller';
|
||||
import { PdfSectionsController } from './pdf-sections.controller';
|
||||
import { AtlasLayersController } from './atlas-layers.controller';
|
||||
import { JournalEntryRowsController } from './journal-entry-rows.controller';
|
||||
import { PluginUserSettingsController } from './plugin-user-settings.controller';
|
||||
import { PluginOAuthController } from './plugin-oauth.controller';
|
||||
import { PluginOAuthService } from './plugin-oauth.service';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
@@ -17,8 +29,8 @@ import { PluginRegistryService } from './registry/registry.service';
|
||||
* widget assets at /plugin-frame/:id/*.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService],
|
||||
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
|
||||
// required addon was just turned off (#plugins dependencies).
|
||||
exports: [PluginRuntimeService],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { readAudit } from './host/plugin-audit';
|
||||
import { pluginBudgetUsage } from './host/create-rpc-host';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
@@ -61,7 +63,7 @@ export interface PluginListItem {
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
list(): { enabled: boolean; plugins: PluginListItem[] } {
|
||||
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
@@ -90,7 +92,7 @@ export class PluginsService {
|
||||
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
|
||||
};
|
||||
});
|
||||
return { enabled: pluginsEnabled(), plugins };
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +125,89 @@ export class PluginsService {
|
||||
return maskSecrets(config, secretKeys);
|
||||
}
|
||||
|
||||
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
|
||||
userSettingsFields(id: string): Array<Record<string, unknown>> {
|
||||
return 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`,
|
||||
)
|
||||
.all(id)
|
||||
.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,
|
||||
required: row.required === 1,
|
||||
secret: row.secret === 1,
|
||||
options: typeof row.options === 'string' && row.options ? safeArray(row.options as string) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private userSecretKeys(id: string): Set<string> {
|
||||
return new Set(
|
||||
(
|
||||
db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
}
|
||||
|
||||
/** A user's own config for a plugin, secrets masked (safe to send to the client). */
|
||||
getUserConfig(id: string, userId: number): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
return maskSecrets(safeParse(row?.config ?? '{}'), this.userSecretKeys(id));
|
||||
}
|
||||
|
||||
/** Merge a user's own settings, encrypting secret fields (SECRET_MASK = keep stored
|
||||
* ciphertext). Only keys declared as `scope:'user'` fields are accepted. Returns masked. */
|
||||
updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const allowed = new Set(
|
||||
(db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map(
|
||||
(r) => r.field_key,
|
||||
),
|
||||
);
|
||||
const secretKeys = this.userSecretKeys(id);
|
||||
const existing = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const config = safeParse(existing?.config ?? '{}');
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!allowed.has(k)) continue; // never store an undeclared key
|
||||
if (secretKeys.has(k)) {
|
||||
if (v === SECRET_MASK) continue; // unchanged secret — keep stored ciphertext
|
||||
config[k] = maybe_encrypt_api_key(v);
|
||||
} else {
|
||||
config[k] = v;
|
||||
}
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`,
|
||||
).run(id, userId, JSON.stringify(config));
|
||||
return maskSecrets(config, secretKeys);
|
||||
}
|
||||
|
||||
/** A user's own config with secrets DECRYPTED — host-only, for runtime `ctx.settings`.
|
||||
* Never sent to a client; the acting user is resolved host-side. */
|
||||
getUserConfigDecrypted(id: string, userId: number): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const config = safeParse(row?.config ?? '{}');
|
||||
const secretKeys = this.userSecretKeys(id);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) out[k] = secretKeys.has(k) && v ? decrypt_api_key(v) : v;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A plugin's error log, newest first. */
|
||||
errors(id: string): Array<{ ts: string; level: string; message: string }> {
|
||||
return db
|
||||
@@ -139,6 +224,11 @@ export class PluginsService {
|
||||
return readAudit(db, id);
|
||||
}
|
||||
|
||||
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
|
||||
budget(id: string): ReturnType<typeof pluginBudgetUsage> {
|
||||
return pluginBudgetUsage(id);
|
||||
}
|
||||
|
||||
/** Read the instance config with secret fields masked. */
|
||||
getInstanceConfig(id: string): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
@@ -162,6 +252,29 @@ function safeParse(json: string): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function safeArray(json: string): unknown[] | undefined {
|
||||
try {
|
||||
const v = JSON.parse(json);
|
||||
return Array.isArray(v) ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-only: one decrypted per-user setting for a plugin (for runtime `ctx.settings`).
|
||||
* Standalone (no Nest DI) so the RPC host wiring can call it directly. */
|
||||
export function readUserSettingDecrypted(pluginId: string, userId: number, key: string): unknown {
|
||||
const isSecret =
|
||||
(db.prepare("SELECT secret FROM plugin_settings_fields WHERE plugin_id = ? AND field_key = ? AND scope = 'user'").get(pluginId, key) as
|
||||
| { secret: number }
|
||||
| undefined)?.secret === 1;
|
||||
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const v = safeParse(row?.config ?? '{}')[key];
|
||||
if (v == null) return undefined;
|
||||
return isSecret ? decrypt_api_key(v) : v;
|
||||
}
|
||||
function maskSecrets(config: Record<string, unknown>, secretKeys: Set<string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
|
||||
@@ -50,11 +50,86 @@ export const KNOWN_METHODS = [
|
||||
'db.exec',
|
||||
'db.query',
|
||||
'db.migrate',
|
||||
'db.tx',
|
||||
'trips.getById',
|
||||
'trips.getPlaces',
|
||||
'trips.getReservations',
|
||||
'trips.getDays',
|
||||
'trips.getAccommodations',
|
||||
'trips.listMine',
|
||||
'reservations.listMine',
|
||||
'reservations.create',
|
||||
'reservations.update',
|
||||
'reservations.delete',
|
||||
'accommodations.create',
|
||||
'accommodations.update',
|
||||
'accommodations.delete',
|
||||
'packing.list',
|
||||
'packing.create',
|
||||
'packing.update',
|
||||
'packing.delete',
|
||||
'packing.listBags',
|
||||
'packing.createBag',
|
||||
'packing.updateBag',
|
||||
'packing.deleteBag',
|
||||
'packing.setBagMembers',
|
||||
'files.list',
|
||||
'files.getContent',
|
||||
'files.create',
|
||||
'files.createLink',
|
||||
'files.update',
|
||||
'files.softDelete',
|
||||
'collab.listNotes',
|
||||
'collab.listPolls',
|
||||
'collab.listMessages',
|
||||
'collab.createNote',
|
||||
'collab.createPoll',
|
||||
'collab.votePoll',
|
||||
'collab.createMessage',
|
||||
'trips.addMember',
|
||||
'trips.removeMember',
|
||||
'trips.create',
|
||||
'journal.listMine',
|
||||
'journal.getEntries',
|
||||
'atlas.visited',
|
||||
'atlas.bucketList',
|
||||
'rates.get',
|
||||
'vacay.mine',
|
||||
'daynotes.list',
|
||||
'daynotes.create',
|
||||
'daynotes.update',
|
||||
'daynotes.delete',
|
||||
'collections.listMine',
|
||||
'collections.get',
|
||||
'collections.create',
|
||||
'collections.update',
|
||||
'collections.savePlace',
|
||||
'collections.copyToTrip',
|
||||
'collections.deletePlace',
|
||||
'atlas.markCountry',
|
||||
'atlas.unmarkCountry',
|
||||
'atlas.markRegion',
|
||||
'atlas.unmarkRegion',
|
||||
'atlas.createBucketItem',
|
||||
'atlas.deleteBucketItem',
|
||||
'vacay.toggleEntry',
|
||||
'vacay.toggleCompanyHoliday',
|
||||
'journal.createEntry',
|
||||
'journal.updateEntry',
|
||||
'journal.deleteEntry',
|
||||
'journal.createJourney',
|
||||
'journal.deleteJourney',
|
||||
'weather.get',
|
||||
'categories.list',
|
||||
'tags.list',
|
||||
'tags.create',
|
||||
'tags.update',
|
||||
'tags.delete',
|
||||
'trips.members',
|
||||
'todos.list',
|
||||
'todos.create',
|
||||
'todos.update',
|
||||
'todos.delete',
|
||||
'costs.getByTrip',
|
||||
'costs.listMine',
|
||||
'costs.create',
|
||||
@@ -76,6 +151,12 @@ export const KNOWN_METHODS = [
|
||||
'users.getById',
|
||||
'ws.broadcastToTrip',
|
||||
'ws.broadcastToUser',
|
||||
'notify.send',
|
||||
'ai.complete',
|
||||
'ai.extract',
|
||||
'oauth.getToken',
|
||||
'scheduler.set',
|
||||
'scheduler.cancel',
|
||||
] as const;
|
||||
export type KnownMethod = (typeof KNOWN_METHODS)[number];
|
||||
|
||||
@@ -84,11 +165,86 @@ export const METHOD_PERMISSION: Record<KnownMethod, string> = {
|
||||
'db.exec': 'db:own',
|
||||
'db.query': 'db:own',
|
||||
'db.migrate': 'db:own',
|
||||
'db.tx': 'db:own',
|
||||
'trips.getById': 'db:read:trips',
|
||||
'trips.getPlaces': 'db:read:trips',
|
||||
'trips.getReservations': 'db:read:trips',
|
||||
'trips.getDays': 'db:read:trips',
|
||||
'trips.getAccommodations': 'db:read:trips',
|
||||
'trips.listMine': 'db:read:trips',
|
||||
'reservations.listMine': 'db:read:trips',
|
||||
'reservations.create': 'db:write:reservations',
|
||||
'reservations.update': 'db:write:reservations',
|
||||
'reservations.delete': 'db:write:reservations',
|
||||
'accommodations.create': 'db:write:accommodations',
|
||||
'accommodations.update': 'db:write:accommodations',
|
||||
'accommodations.delete': 'db:write:accommodations',
|
||||
'packing.list': 'db:read:packing',
|
||||
'packing.create': 'db:write:packing',
|
||||
'packing.update': 'db:write:packing',
|
||||
'packing.delete': 'db:write:packing',
|
||||
'packing.listBags': 'db:write:packing',
|
||||
'packing.createBag': 'db:write:packing',
|
||||
'packing.updateBag': 'db:write:packing',
|
||||
'packing.deleteBag': 'db:write:packing',
|
||||
'packing.setBagMembers': 'db:write:packing',
|
||||
'files.list': 'db:read:files',
|
||||
'files.getContent': 'db:read:files:content',
|
||||
'files.create': 'db:write:files',
|
||||
'files.createLink': 'db:write:files',
|
||||
'files.update': 'db:write:files',
|
||||
'files.softDelete': 'db:write:files',
|
||||
'collab.listNotes': 'db:read:collab',
|
||||
'collab.listPolls': 'db:read:collab',
|
||||
'collab.listMessages': 'db:read:collab',
|
||||
'collab.createNote': 'db:write:collab',
|
||||
'collab.createPoll': 'db:write:collab',
|
||||
'collab.votePoll': 'db:write:collab',
|
||||
'collab.createMessage': 'db:write:collab',
|
||||
'trips.addMember': 'db:write:members',
|
||||
'trips.removeMember': 'db:write:members',
|
||||
'trips.create': 'db:create:trips',
|
||||
'journal.listMine': 'db:read:journal',
|
||||
'journal.getEntries': 'db:read:journal',
|
||||
'atlas.visited': 'db:read:atlas',
|
||||
'atlas.bucketList': 'db:read:atlas',
|
||||
'rates.get': 'rates:read',
|
||||
'vacay.mine': 'db:read:vacay',
|
||||
'daynotes.list': 'db:read:daynotes',
|
||||
'daynotes.create': 'db:write:daynotes',
|
||||
'daynotes.update': 'db:write:daynotes',
|
||||
'daynotes.delete': 'db:write:daynotes',
|
||||
'collections.listMine': 'db:read:collections',
|
||||
'collections.get': 'db:read:collections',
|
||||
'collections.create': 'db:write:collections',
|
||||
'collections.update': 'db:write:collections',
|
||||
'collections.savePlace': 'db:write:collections',
|
||||
'collections.copyToTrip': 'db:write:collections',
|
||||
'collections.deletePlace': 'db:write:collections',
|
||||
'atlas.markCountry': 'db:write:atlas',
|
||||
'atlas.unmarkCountry': 'db:write:atlas',
|
||||
'atlas.markRegion': 'db:write:atlas',
|
||||
'atlas.unmarkRegion': 'db:write:atlas',
|
||||
'atlas.createBucketItem': 'db:write:atlas',
|
||||
'atlas.deleteBucketItem': 'db:write:atlas',
|
||||
'vacay.toggleEntry': 'db:write:vacay',
|
||||
'vacay.toggleCompanyHoliday': 'db:write:vacay',
|
||||
'journal.createEntry': 'db:write:journal',
|
||||
'journal.updateEntry': 'db:write:journal',
|
||||
'journal.deleteEntry': 'db:write:journal',
|
||||
'journal.createJourney': 'db:write:journal',
|
||||
'journal.deleteJourney': 'db:write:journal',
|
||||
'weather.get': 'weather:read',
|
||||
'categories.list': 'db:read:categories',
|
||||
'tags.list': 'db:read:tags',
|
||||
'tags.create': 'db:write:tags',
|
||||
'tags.update': 'db:write:tags',
|
||||
'tags.delete': 'db:write:tags',
|
||||
'trips.members': 'db:read:trips',
|
||||
'todos.list': 'db:read:todos',
|
||||
'todos.create': 'db:write:todos',
|
||||
'todos.update': 'db:write:todos',
|
||||
'todos.delete': 'db:write:todos',
|
||||
'costs.getByTrip': 'db:read:costs',
|
||||
'costs.listMine': 'db:read:costs',
|
||||
'costs.create': 'db:write:costs',
|
||||
@@ -110,6 +266,14 @@ export const METHOD_PERMISSION: Record<KnownMethod, string> = {
|
||||
'users.getById': 'db:read:users',
|
||||
'ws.broadcastToTrip': 'ws:broadcast:trip',
|
||||
'ws.broadcastToUser': 'ws:broadcast:user',
|
||||
'notify.send': 'notify:send',
|
||||
'ai.complete': 'ai:invoke',
|
||||
'ai.extract': 'ai:invoke',
|
||||
'oauth.getToken': 'oauth:client',
|
||||
// Scheduling a userless future callback is the same risk class as a cron job, so
|
||||
// it rides on the existing jobs:run grant (no new permission, no re-consent).
|
||||
'scheduler.set': 'jobs:run',
|
||||
'scheduler.cancel': 'jobs:run',
|
||||
};
|
||||
|
||||
/** All permission strings the host understands (unknown ones are rejected at activation). */
|
||||
@@ -119,12 +283,38 @@ export const KNOWN_PERMISSIONS = [
|
||||
'db:read:users',
|
||||
'db:read:costs',
|
||||
'db:read:packing',
|
||||
'db:write:packing',
|
||||
'db:read:files',
|
||||
'db:read:files:content',
|
||||
'db:write:files',
|
||||
'db:read:collab',
|
||||
'db:write:collab',
|
||||
'db:write:members',
|
||||
'db:create:trips',
|
||||
'db:read:journal',
|
||||
'db:read:atlas',
|
||||
'rates:read',
|
||||
'db:read:vacay',
|
||||
'db:read:daynotes',
|
||||
'db:read:collections',
|
||||
'db:write:collections',
|
||||
'db:write:atlas',
|
||||
'db:write:vacay',
|
||||
'db:write:journal',
|
||||
'db:read:categories',
|
||||
'db:read:tags',
|
||||
'db:write:tags',
|
||||
'db:read:todos',
|
||||
'db:write:todos',
|
||||
'weather:read',
|
||||
'db:write:daynotes',
|
||||
'db:write:costs',
|
||||
'db:write:places',
|
||||
'db:write:days',
|
||||
'db:write:itinerary',
|
||||
'db:write:trips',
|
||||
'db:write:reservations',
|
||||
'db:write:accommodations',
|
||||
'db:meta',
|
||||
'ws:broadcast:trip',
|
||||
'ws:broadcast:user',
|
||||
@@ -132,8 +322,23 @@ export const KNOWN_PERMISSIONS = [
|
||||
'hook:calendar-source',
|
||||
'hook:place-detail-provider',
|
||||
'hook:trip-warning-provider',
|
||||
'hook:table-contributor',
|
||||
'hook:map-marker-provider',
|
||||
'hook:pdf-section-provider',
|
||||
'hook:atlas-layer-provider',
|
||||
'hook:journal-entry-provider',
|
||||
'hook:trip-card-provider',
|
||||
// Data-subject-rights hook: the host calls the plugin's deleteUserData /
|
||||
// exportUserData when a TREK account is erased or its data is exported. Userless
|
||||
// (the plugin only receives a userId and acts on its OWN db), so it grants no
|
||||
// read into core data — it exists so a plugin can honour GDPR erasure/portability.
|
||||
'hook:user-data',
|
||||
'events:subscribe',
|
||||
'jobs:run',
|
||||
'http:outbound',
|
||||
'notify:send',
|
||||
'ai:invoke',
|
||||
'oauth:client',
|
||||
] as const;
|
||||
|
||||
export function isKnownPermission(p: string): boolean {
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface RegistryEntry {
|
||||
reviewedAt?: string | null;
|
||||
/** base64 minisign author public key — stable across versions, TOFU-pinned. */
|
||||
authorPublicKey?: string;
|
||||
/** Release-asset downloads across all versions, aggregated by the registry's stats cron. */
|
||||
downloadCount?: number | null;
|
||||
versions: RegistryVersion[];
|
||||
}
|
||||
interface Registry {
|
||||
@@ -124,6 +126,7 @@ export class PluginRegistryService {
|
||||
return {
|
||||
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
|
||||
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
|
||||
downloadCount: p.downloadCount ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
|
||||
@@ -161,7 +164,7 @@ export class PluginRegistryService {
|
||||
const data = {
|
||||
id: entry.id, name: entry.name, author: entry.author, description: entry.description,
|
||||
repo: entry.repo, homepage: entry.homepage ?? null, tags: entry.tags ?? [], type: entry.type,
|
||||
reviewedAt: entry.reviewedAt ?? null,
|
||||
reviewedAt: entry.reviewedAt ?? null, downloadCount: entry.downloadCount ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
|
||||
@@ -79,6 +79,11 @@ function installSdkInjection(requirePlugin: NodeJS.Require): void {
|
||||
};
|
||||
}
|
||||
|
||||
// True once the plugin has loaded successfully. A subsequent async throw is then a
|
||||
// runtime crash (supervisor restarts with backoff), not a load failure — so we don't
|
||||
// send 'load-error' after activation, which the supervisor treats as a terminal disable.
|
||||
let activated = false;
|
||||
|
||||
async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
// Seal the raw IPC surface BEFORE any plugin code is required — this is the
|
||||
@@ -94,10 +99,11 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
def = mod && mod.default ? (mod.default as PluginDefinition) : (mod as PluginDefinition);
|
||||
ctx = createPluginContext(pluginId, config, transport);
|
||||
if (typeof def.onLoad === 'function') await def.onLoad(ctx);
|
||||
// Report the declared routes (with their index = routeId) and job ids so the
|
||||
// host can proxy HTTP and schedule jobs without re-parsing the manifest.
|
||||
// Report the declared routes (with their index = routeId) and jobs (id + cron
|
||||
// schedule) so the host can proxy HTTP and SCHEDULE the jobs without re-parsing
|
||||
// the manifest.
|
||||
const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false }));
|
||||
const jobs = (def.jobs ?? []).map((j) => j.id);
|
||||
const jobs = (def.jobs ?? []).map((j) => ({ id: j.id, schedule: j.schedule }));
|
||||
const hooks = Object.keys((def.hooks ?? {}) as Record<string, unknown>);
|
||||
const events = (def.events ?? []).map((e) => e.on);
|
||||
// Inter-plugin surface: the callable exports this plugin implements, and the
|
||||
@@ -105,6 +111,7 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
const exportNames = Object.keys((def.exports ?? {}) as Record<string, unknown>);
|
||||
const subscriptions = (def.subscriptions ?? []).map((s) => ({ plugin: s.plugin, event: s.event }));
|
||||
send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, events, exports: exportNames, subscriptions } });
|
||||
activated = true; // past load: a later async throw is a runtime CRASH, not a load failure
|
||||
// An immediate first heartbeat confirms liveness without waiting a full interval.
|
||||
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
|
||||
} catch (e) {
|
||||
@@ -138,6 +145,26 @@ async function handleInvoke(req: { id: string; method: string; params: Record<st
|
||||
if (!job) throw new Error(`no job ${jobId}`);
|
||||
await job.handler(invCtx);
|
||||
respond(true, { ok: true });
|
||||
} else if (req.method === 'invoke.scheduled') {
|
||||
// A scheduled task the plugin registered via ctx.scheduler fired. Userless like
|
||||
// a job (no acting user, so trip reads are refused). The handler gets the task
|
||||
// name + its payload so one `scheduled` handler can route multiple timers.
|
||||
const name = req.params.name as string;
|
||||
const payload = req.params.payload;
|
||||
if (typeof def.scheduled === 'function') await def.scheduled({ name, payload }, invCtx);
|
||||
respond(true, { ok: true });
|
||||
} else if (req.method === 'invoke.deleteUserData') {
|
||||
// A TREK account was erased. Userless (like a job) — the handler only learns
|
||||
// the userId and erases its OWN per-user rows. Always ACK, even with no handler,
|
||||
// so the host can drop the durable erasure-queue row (nothing to erase == done).
|
||||
const userId = req.params.userId as number;
|
||||
if (typeof def.deleteUserData === 'function') await def.deleteUserData({ userId }, invCtx);
|
||||
respond(true, { ok: true });
|
||||
} else if (req.method === 'invoke.exportUserData') {
|
||||
// GDPR portability: return what this plugin holds about the user (own db only).
|
||||
const userId = req.params.userId as number;
|
||||
const data = typeof def.exportUserData === 'function' ? await def.exportUserData({ userId }, invCtx) : undefined;
|
||||
respond(true, { ok: true, data });
|
||||
} else if (req.method === 'invoke.hook') {
|
||||
// Host→plugin provider call: core asks a hook the plugin implements (e.g.
|
||||
// placeDetailProvider) for data. The hook method gets its args + the per-
|
||||
@@ -156,7 +183,16 @@ async function handleInvoke(req: { id: string; method: string; params: Record<st
|
||||
// to the fact of the event, using the plugin's own db / outbound / broadcasts.
|
||||
const eventName = req.params.event as string;
|
||||
const tripId = req.params.tripId as number;
|
||||
const payload = { event: eventName, tripId };
|
||||
// entity/entityId are the host-derived hint (which entity changed); snapshot
|
||||
// is present only when the host verified this plugin's db:read:* grant for the
|
||||
// family. Still no user — a trip read from this handler remains refused.
|
||||
const payload = {
|
||||
event: eventName,
|
||||
tripId,
|
||||
entity: req.params.entity as string | undefined,
|
||||
entityId: req.params.entityId as number | undefined,
|
||||
snapshot: req.params.snapshot as Record<string, unknown> | undefined,
|
||||
};
|
||||
for (const sub of def.events ?? []) {
|
||||
if (sub.on === '*' || sub.on === eventName) await sub.handler(payload, invCtx);
|
||||
}
|
||||
@@ -400,7 +436,15 @@ function installEgressGuard(egress: string[]): void {
|
||||
const realConnect = proto.connect;
|
||||
proto.connect = function (this: unknown, ...args: unknown[]): unknown {
|
||||
const target = classifyConnect(args, (s) => net.isIP(s) !== 0);
|
||||
if (target.kind === 'local') return realConnect.apply(this, args); // unix socket / pipe
|
||||
if (target.kind === 'local') {
|
||||
// A unix-socket / named-pipe connect is a host-local pivot — a malicious plugin
|
||||
// could reach docker.sock or a local DB socket, exactly what the private-IP block
|
||||
// exists to stop, and Node's --permission model doesn't gate socket connects.
|
||||
// Refuse it under the SAME policy: blocked by default, allowed only when the
|
||||
// operator explicitly opted into private egress.
|
||||
if (blockPrivate) throw new Error(`egress: connecting to a local socket/pipe (${target.host}) is not allowed`);
|
||||
return realConnect.apply(this, args);
|
||||
}
|
||||
if (!allowed(target.host)) {
|
||||
throw new Error(`egress: ${target.host} is not in the plugin's declared hosts`);
|
||||
}
|
||||
@@ -514,14 +558,15 @@ heartbeat.unref?.();
|
||||
|
||||
// A plugin that throws asynchronously must not take the host down — it only
|
||||
// crashes THIS child, which the supervisor detects and restarts/disables.
|
||||
process.on('uncaughtException', (e) => {
|
||||
send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } });
|
||||
const onFatal = (e: unknown) => {
|
||||
// Before activation: a throw during load fails activation (terminal). After activation:
|
||||
// it's a runtime crash — just exit and let the supervisor's onExit run crash/backoff,
|
||||
// so one late async throw restarts the plugin instead of permanently disabling it.
|
||||
if (!activated) send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } });
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (e) => {
|
||||
send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } });
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
process.on('uncaughtException', onFatal);
|
||||
process.on('unhandledRejection', onFatal);
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -14,10 +14,20 @@ export const PLUGIN_API_VERSION = 1 as const;
|
||||
export interface PluginContext {
|
||||
readonly id: string;
|
||||
readonly config: Readonly<Record<string, unknown>>;
|
||||
/** The ACTING USER's own value for one of this plugin's `scope:'user'` settings fields
|
||||
* (decrypted host-side). Returns undefined for an unset value or a userless context
|
||||
* (job/onLoad) — fall back to `config` (the admin-owned instance settings) there. */
|
||||
settings: {
|
||||
get(key: string): Promise<unknown>;
|
||||
};
|
||||
db: {
|
||||
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
|
||||
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
|
||||
migrate(id: string, sql: string): Promise<{ applied: boolean }>;
|
||||
/** Run several statements atomically on your OWN db — all commit or all roll
|
||||
* back. Each op is one statement; reads see the batch's own earlier writes.
|
||||
* A read returns `{ rows }`, a write `{ changes }`. Max 100 statements. */
|
||||
tx(ops: Array<{ sql: string; args?: unknown[] }>): Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>;
|
||||
};
|
||||
trips: {
|
||||
// `asUserId` is accepted for source compatibility but IGNORED by the host —
|
||||
@@ -27,19 +37,219 @@ export interface PluginContext {
|
||||
// no user and its trip reads are refused.
|
||||
getById(tripId: number, asUserId?: number): Promise<unknown>;
|
||||
getPlaces(tripId: number, asUserId?: number): Promise<unknown[]>;
|
||||
/** Hydrated like the REST list: each row carries `endpoints`, `day_positions` + the day/place joins. */
|
||||
getReservations(tripId: number, asUserId?: number): Promise<unknown[]>;
|
||||
/** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs 'db:read:trips'. */
|
||||
getDays(tripId: number): Promise<unknown[]>;
|
||||
/** The trip's lodging blocks (day_accommodations) with joined place fields. Needs 'db:read:trips'. */
|
||||
getAccommodations(tripId: number): Promise<unknown[]>;
|
||||
/** Every trip the acting user owns or is a member of (for dashboards/aggregates). Needs 'db:read:trips'. */
|
||||
listMine(): Promise<unknown[]>;
|
||||
/** Update trip fields (title/dates/currency/reminder_days/...); needs 'db:write:trips' + the acting user's 'trip_edit' permission. */
|
||||
update(tripId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Create a new trip owned by the acting user (importers). `title` required. Needs 'db:create:trips' + the acting user's 'trip_create'. */
|
||||
create(input: { title: string; description?: string; start_date?: string; end_date?: string; currency?: string; reminder_days?: number; day_count?: number }): Promise<unknown>;
|
||||
/** The trip's member roster (id + display fields only). Membership-checked. Needs 'db:read:trips'. */
|
||||
members(tripId: number): Promise<unknown[]>;
|
||||
/** Add a user to a trip (GRANTS ACCESS — its own permission). Needs 'db:write:members' + the acting user's 'member_manage'. */
|
||||
addMember(tripId: number, userId: number): Promise<{ joined: boolean; tripId: number }>;
|
||||
/** Remove a member from a trip (not the owner). Needs `db:write:members` + member_manage. */
|
||||
removeMember(tripId: number, userId: number): Promise<{ removed: boolean }>;
|
||||
};
|
||||
// Reservations (bookings). `listMine` reads across every accessible trip (needs
|
||||
// 'db:read:trips'); create/update/delete need 'db:write:reservations' + the acting
|
||||
// user's 'reservation_edit' permission, and reuse the app's accommodation/budget/
|
||||
// notification side effects 1:1.
|
||||
reservations: {
|
||||
/** Every reservation across the acting user's accessible trips. Needs 'db:read:trips'. */
|
||||
listMine(): Promise<unknown[]>;
|
||||
/** Create a booking on a trip. An `endpoints` array (from/to/stop legs for flights,
|
||||
* trains, ferries) is persisted with it. Needs 'db:write:reservations' + 'reservation_edit'. */
|
||||
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Update a booking on a trip. `endpoints` semantics: omitted = keep, [] = delete all,
|
||||
* array = replace (endpoint ids are NOT stable). Needs 'db:write:reservations' + 'reservation_edit'. */
|
||||
update(tripId: number, reservationId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Delete a booking from a trip. Needs 'db:write:reservations' + 'reservation_edit'. */
|
||||
delete(tripId: number, reservationId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
// Lodging blocks (day_accommodations): a hotel span from a start day to an end day.
|
||||
// Reads ride on 'db:read:trips' (ctx.trips.getAccommodations); writes need
|
||||
// 'db:write:accommodations' + the acting user's 'day_edit' permission (like the
|
||||
// REST path — accommodations live in the day service, not the bookings one).
|
||||
// Creating one auto-creates its partner hotel reservation, exactly like the app.
|
||||
accommodations: {
|
||||
create(tripId: number, input: { place_id: number; start_day_id: number; end_day_id: number; check_in?: string | null; check_in_end?: string | null; check_out?: string | null; confirmation?: string | null; notes?: string | null }): Promise<unknown>;
|
||||
update(tripId: number, accommodationId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
delete(tripId: number, accommodationId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
// Read-only views of other trip subsystems (#1429 eco). Membership-checked like
|
||||
// `trips`; each needs its own db:read:* scope.
|
||||
packing: {
|
||||
/** A trip's packing items (hydrated bags/assignees). Needs 'db:read:packing'. */
|
||||
list(tripId: number): Promise<unknown[]>;
|
||||
/** Add a packing item (owner = acting user). Needs 'db:write:packing' + 'packing_edit'. */
|
||||
create(tripId: number, input: { name: string; category?: string; checked?: boolean; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] }): Promise<unknown>;
|
||||
/** Update a packing item. Needs 'db:write:packing' + 'packing_edit'. */
|
||||
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Delete a packing item. Needs 'db:write:packing' + 'packing_edit'. */
|
||||
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
|
||||
/** List/create/update/delete packing bags + set members (no privacy). Needs 'db:write:packing' + 'packing_edit'. */
|
||||
listBags(tripId: number): Promise<unknown[]>;
|
||||
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
|
||||
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
deleteBag(tripId: number, bagId: number): Promise<{ deleted: boolean }>;
|
||||
setBagMembers(tripId: number, bagId: number, userIds: number[]): Promise<unknown>;
|
||||
};
|
||||
files: {
|
||||
/** A trip's files, trash excluded. Needs 'db:read:files'. */
|
||||
list(tripId: number): Promise<unknown[]>;
|
||||
/** A file's bytes as base64 (10MB cap; trashed files refused). Needs 'db:read:files:content'. Returns { name, mimetype, size, content_base64 }. */
|
||||
getContent(tripId: number, fileId: number): Promise<{ name: string; mimetype: string; size: number; content_base64: string }>;
|
||||
/** Store base64 content as a trip file (10MB cap, blocked extensions refused). Needs 'db:write:files' + 'file_upload'. */
|
||||
create(tripId: number, input: { name: string; content_base64: string; mimetype?: string; description?: string; place_id?: number; reservation_id?: number }): Promise<unknown>;
|
||||
/** Link an existing file to a same-trip reservation/place/assignment. Needs 'db:write:files' + 'file_edit'. */
|
||||
createLink(tripId: number, fileId: number, opts: { reservation_id?: number; assignment_id?: number; place_id?: number }): Promise<unknown>;
|
||||
/** Update a file's description/links. Needs 'db:write:files' + 'file_edit'. */
|
||||
update(tripId: number, fileId: number, input: { description?: string; place_id?: number | null; reservation_id?: number | null }): Promise<unknown>;
|
||||
/** Move a file to the trash. Needs 'db:write:files' + 'file_delete'. */
|
||||
softDelete(tripId: number, fileId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
/** Collab content (notes/polls/chat). Reads need 'db:read:collab'; writes need 'db:write:collab' + the acting user's 'collab_edit'. Both need the Collab addon. */
|
||||
collab: {
|
||||
/** A trip's collab notes (hydrated with author + attachments). Needs 'db:read:collab'. */
|
||||
listNotes(tripId: number): Promise<unknown[]>;
|
||||
/** A trip's polls (with options + voters). Needs 'db:read:collab'. */
|
||||
listPolls(tripId: number): Promise<unknown[]>;
|
||||
/** A trip's chat messages (newest 100, oldest first; `before` = a message id to page back). Needs 'db:read:collab'. */
|
||||
listMessages(tripId: number, before?: number): Promise<unknown[]>;
|
||||
createNote(tripId: number, input: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }): Promise<unknown>;
|
||||
createPoll(tripId: number, input: { question: string; options: unknown[]; multiple?: boolean; deadline?: string }): Promise<unknown>;
|
||||
votePoll(tripId: number, pollId: number, optionIndex: number): Promise<unknown>;
|
||||
createMessage(tripId: number, text: string, replyTo?: number): Promise<unknown>;
|
||||
};
|
||||
/** Host-mediated notification. The plugin supplies only target + plain text; the host
|
||||
* owns delivery + preferences. Recipient is FORCED to the acting user (scope 'user',
|
||||
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs 'notify:send'. */
|
||||
notify: {
|
||||
send(input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise<{ sent: boolean }>;
|
||||
};
|
||||
/** Host-mediated LLM using the admin/user-configured provider — the plugin never holds a
|
||||
* key. `complete` returns { text }; `extract` returns { results } for your JSON schema.
|
||||
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs 'ai:invoke'. */
|
||||
ai: {
|
||||
complete(prompt: string, system?: string): Promise<{ text: string }>;
|
||||
extract(text: string, jsonSchema: object, prompt?: string): Promise<{ results: Record<string, unknown>[] }>;
|
||||
};
|
||||
/** Host-brokered outbound OAuth: a short-lived access token for the ACTING USER of a
|
||||
* third-party service the host connected on their behalf (Settings → Plugins → Connect).
|
||||
* Returns null when the user hasn't connected or in a userless context. The host holds
|
||||
* the refresh token + client secret — you never see them. Needs 'oauth:client'. */
|
||||
oauth: {
|
||||
getAccessToken(): Promise<string | null>;
|
||||
};
|
||||
/** Persistent, userless scheduling — a future callback into your `scheduled` handler
|
||||
* that survives restarts. Same risk class + grant as background jobs: needs 'jobs:run',
|
||||
* runs with NO acting user (trip reads refused; own db + declared egress only). Caps:
|
||||
* ≤100 tasks/plugin, ≤128-char name, ≤8 KB payload, recurring interval ≥60s, ≤1 year out.
|
||||
* `set` is an upsert by name; `cancel` removes it. */
|
||||
scheduler: {
|
||||
/** Fire once at an absolute epoch-ms time. */
|
||||
at(whenMs: number, name: string, payload?: unknown): Promise<{ scheduled: boolean }>;
|
||||
/** Fire once after `ms` from now. */
|
||||
in(ms: number, name: string, payload?: unknown): Promise<{ scheduled: boolean }>;
|
||||
/** Fire repeatedly every `ms` (first fire after `ms`). */
|
||||
every(ms: number, name: string, payload?: unknown): Promise<{ scheduled: boolean }>;
|
||||
/** Cancel a scheduled task by name. */
|
||||
cancel(name: string): Promise<{ cancelled: boolean }>;
|
||||
};
|
||||
/** Host weather cache by coordinates (+ optional YYYY-MM-DD). Tenant-free. Needs 'weather:read'. */
|
||||
weather: {
|
||||
get(lat: number, lng: number, date?: string): Promise<unknown>;
|
||||
};
|
||||
/** Exchange rates (tenant-free, cached upstream). Needs 'rates:read'. */
|
||||
rates: {
|
||||
/** A map of quote → rate relative to `base` (e.g. base 'EUR' → { USD: 1.08, ... }). `null` on an upstream failure. */
|
||||
get(base: string): Promise<Record<string, number> | null>;
|
||||
};
|
||||
/** The global place-category reference list (read-only). Needs 'db:read:categories'. */
|
||||
categories: {
|
||||
list(): Promise<unknown[]>;
|
||||
};
|
||||
/** The acting user's own tags. Needs 'db:read:tags' (list) / 'db:write:tags' (create/update/delete). */
|
||||
tags: {
|
||||
list(): Promise<unknown[]>;
|
||||
create(input: { name: string; color?: string }): Promise<unknown>;
|
||||
update(tagId: number, input: { name?: string; color?: string }): Promise<unknown>;
|
||||
delete(tagId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
/** A trip's to-dos. Needs 'db:read:todos' (list) / 'db:write:todos' + 'packing_edit' (create/update/delete). */
|
||||
todos: {
|
||||
list(tripId: number): Promise<unknown[]>;
|
||||
create(tripId: number, input: { name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number }): Promise<unknown>;
|
||||
update(tripId: number, todoId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
delete(tripId: number, todoId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
// The acting user's OWN subsystem data across all their trips (not one trip), each
|
||||
// gated on its addon being enabled — mirrors the addon's own REST/MCP readers.
|
||||
journal: {
|
||||
/** The acting user's journals. Needs 'db:read:journal' + the journey addon. */
|
||||
listMine(): Promise<unknown[]>;
|
||||
/** The entries of one of the acting user's journeys (photos/story/checkins), access-checked. Needs 'db:read:journal'. */
|
||||
getEntries(journeyId: number): Promise<unknown[]>;
|
||||
/** Create an entry on a journey the acting user can edit. Needs 'db:write:journal'. */
|
||||
createEntry(journeyId: number, input: { entry_date: string; [k: string]: unknown }): Promise<unknown>;
|
||||
/** Update an entry (owner/contributor-gated). Needs 'db:write:journal'. */
|
||||
updateEntry(entryId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Delete an entry (owner/contributor-gated). Needs 'db:write:journal'. */
|
||||
deleteEntry(entryId: number): Promise<{ deleted: boolean }>;
|
||||
/** Create a new journal owned by the acting user (an importer bootstraps the journal
|
||||
* it then fills with entries). Needs 'db:write:journal'. */
|
||||
createJourney(input: { title: string; subtitle?: string; trip_ids?: number[] }): Promise<unknown>;
|
||||
/** Delete one of the acting user's journals. Needs 'db:write:journal'. */
|
||||
deleteJourney(journeyId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
atlas: {
|
||||
/** The acting user's visited countries + regions. Needs 'db:read:atlas' + the atlas addon. */
|
||||
visited(): Promise<{ countries: unknown[]; regions: unknown[] }>;
|
||||
/** The acting user's bucket-list items. Needs 'db:read:atlas'. */
|
||||
bucketList(): Promise<unknown[]>;
|
||||
/** Mark/unmark the ACTING USER's own visited countries/regions + bucket list. Needs 'db:write:atlas'. */
|
||||
markCountry(code: string): Promise<unknown>;
|
||||
unmarkCountry(code: string): Promise<unknown>;
|
||||
markRegion(regionCode: string, countryCode: string, regionName?: string): Promise<unknown>;
|
||||
unmarkRegion(regionCode: string): Promise<unknown>;
|
||||
createBucketItem(input: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string }): Promise<unknown>;
|
||||
deleteBucketItem(itemId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
vacay: {
|
||||
/** The acting user's vacation plan data. Needs 'db:read:vacay' + the vacay addon. */
|
||||
mine(): Promise<unknown>;
|
||||
/** Toggle the ACTING USER's own PTO day on their active plan. Needs 'db:write:vacay'. */
|
||||
toggleEntry(date: string): Promise<{ action: string }>;
|
||||
/** Toggle a company holiday on the acting user's active plan. Needs 'db:write:vacay'. */
|
||||
toggleCompanyHoliday(date: string, note?: string): Promise<{ action: string }>;
|
||||
};
|
||||
collections: {
|
||||
/** The acting user's saved-place collections. Needs 'db:read:collections' + the collections addon. */
|
||||
listMine(): Promise<unknown>;
|
||||
/** One of the acting user's collections by id. Needs 'db:read:collections' + the collections addon. */
|
||||
get(id: number): Promise<unknown>;
|
||||
/** Collections write (per-collection role enforced by the service). Needs 'db:write:collections'. */
|
||||
create(input: Record<string, unknown>): Promise<unknown>;
|
||||
update(id: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
savePlace(input: Record<string, unknown>): Promise<unknown>;
|
||||
copyToTrip(input: Record<string, unknown>): Promise<unknown>;
|
||||
deletePlace(placeId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
daynotes: {
|
||||
/** A day's notes on a trip (membership-checked). Needs 'db:read:daynotes'. */
|
||||
list(tripId: number, dayId: number): Promise<unknown[]>;
|
||||
/** Add a note to a day. Needs 'db:write:daynotes' + the acting user's 'day_edit'. */
|
||||
create(tripId: number, dayId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Update a day note. Needs 'db:write:daynotes' + 'day_edit'. */
|
||||
update(tripId: number, dayId: number, noteId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
/** Delete a day note. Needs 'db:write:daynotes' + 'day_edit'. */
|
||||
delete(tripId: number, dayId: number, noteId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
// "Costs" = budget items. Reads are membership-checked against the current
|
||||
// invocation's user (like `trips`); `create` additionally needs the acting
|
||||
@@ -72,10 +282,10 @@ export interface PluginContext {
|
||||
// you can enrich core entities without forking the schema. The entity must belong
|
||||
// to a trip the current user can access. Values are JSON-serialisable.
|
||||
meta: {
|
||||
get(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<unknown>;
|
||||
set(entityType: 'trip' | 'place' | 'day', entityId: number, key: string, value: unknown): Promise<unknown>;
|
||||
list(entityType: 'trip' | 'place' | 'day', entityId: number): Promise<Record<string, unknown>>;
|
||||
delete(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<{ deleted: boolean }>;
|
||||
get(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<unknown>;
|
||||
set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise<unknown>;
|
||||
list(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number): Promise<Record<string, unknown>>;
|
||||
delete(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
users: {
|
||||
getById(id: number): Promise<unknown>;
|
||||
@@ -106,6 +316,11 @@ export interface PluginRequest {
|
||||
path: string;
|
||||
query: Record<string, unknown>;
|
||||
body: unknown;
|
||||
/** Inbound headers — ONLY populated on `auth:false` routes (webhooks), and only an
|
||||
* explicit, credential-free allowlist (signature + event headers from the common
|
||||
* providers; never Cookie/Authorization/session). Empty on authenticated routes.
|
||||
* Verify a provider signature against a secret you hold in `ctx.config`/`ctx.settings`. */
|
||||
headers: Record<string, string>;
|
||||
user: { id: number; username: string; isAdmin: boolean } | null;
|
||||
}
|
||||
export interface PluginResponse {
|
||||
@@ -129,13 +344,13 @@ export interface PluginJob {
|
||||
// invocation ctx, so any trip reads it makes bind to the authenticated user. ──
|
||||
export interface Photo { id: string; title?: string; thumbnailUrl: string; fullUrl: string; takenAt?: string; }
|
||||
export interface PhotoProvider {
|
||||
search(query: string, opts: { page: number; limit: number }): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
|
||||
getById(id: string): Promise<Photo | null>;
|
||||
search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
|
||||
getById(id: string, ctx: PluginContext): Promise<Photo | null>;
|
||||
}
|
||||
export interface CalendarEvent { id: string; title: string; start: string; end: string; allDay: boolean; }
|
||||
export interface CalendarSource {
|
||||
getName(): string;
|
||||
getEvents(userId: number, start: string, end: string): Promise<CalendarEvent[]>;
|
||||
getName(ctx: PluginContext): string;
|
||||
getEvents(userId: number, start: string, end: string, ctx: PluginContext): Promise<CalendarEvent[]>;
|
||||
}
|
||||
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
|
||||
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
|
||||
@@ -148,11 +363,119 @@ export interface WarningProvider {
|
||||
getWarnings(tripId: number, ctx: PluginContext): Promise<TripWarning[]>;
|
||||
}
|
||||
|
||||
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job)
|
||||
* and receive only the event name + tripId — never the payload. Needs 'events:subscribe'. */
|
||||
/** Host-rendered contributions into a native trip-planner view (reservations/places/day),
|
||||
* keyed to an entity by id (#plugins). DECLARATIVE ONLY — a column is text/badge/link,
|
||||
* an action is a labelled button whose target opens your sandboxed frame or calls a
|
||||
* route. Never raw HTML/markup; the host renders + sanitizes everything. */
|
||||
export type ContributionTone = 'default' | 'success' | 'warn' | 'danger';
|
||||
/** An extra read-only cell/badge on an entity's row/card. */
|
||||
export interface TableColumnContribution {
|
||||
kind: 'column';
|
||||
entityId: number; // the reservation/place/day id this attaches to
|
||||
id: string; // stable per-contribution id (for React keys / dedupe)
|
||||
label: string;
|
||||
value?: string;
|
||||
url?: string; // http/https/mailto only — the host rejects any other scheme
|
||||
icon?: string; // a lucide icon name, resolved by the host
|
||||
tone?: ContributionTone;
|
||||
}
|
||||
/** A labelled button on an entity's row/card; its target opens your sandboxed frame
|
||||
* (`{kind:'frame', sub}`) or invokes one of your routes (`{kind:'route', method, sub}`). */
|
||||
export interface TableActionContribution {
|
||||
kind: 'action';
|
||||
entityId: number;
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string };
|
||||
}
|
||||
export type TableContribution = TableColumnContribution | TableActionContribution;
|
||||
export interface TableContributor {
|
||||
/** `view` is one of 'reservations' | 'places' | 'day' | 'costs' | 'packing' | 'files'.
|
||||
* Runs with the current user bound, on a short timeout; a slow/failing call is
|
||||
* skipped, never fatal. */
|
||||
getContributions(view: string, tripId: number, ctx: PluginContext): Promise<TableContribution[]>;
|
||||
}
|
||||
|
||||
/** A bounded marker the host renders onto the trip map (#587). Declarative only —
|
||||
* the host draws a Leaflet marker + popup; plugin JS never runs on the map canvas. */
|
||||
export interface MapMarkerContribution {
|
||||
id: string; // stable per-marker id (React key / dedupe)
|
||||
lat: number; // -90..90
|
||||
lng: number; // -180..180
|
||||
label?: string; // short label shown in the popup title
|
||||
popupText?: string; // one line of body text (host-sanitized, plain text)
|
||||
url?: string; // http/https/mailto only — the host rejects any other scheme
|
||||
icon?: string; // a lucide icon name, resolved by the host
|
||||
tone?: ContributionTone;
|
||||
}
|
||||
export interface MapMarkerProvider {
|
||||
/** Return markers to overlay on a trip's map. Runs with the current user bound,
|
||||
* on a short timeout; the host caps the marker count and skips a failing call. */
|
||||
getMarkers(tripId: number, ctx: PluginContext): Promise<MapMarkerContribution[]>;
|
||||
}
|
||||
|
||||
/** A text-only section the host appends to a trip's PDF export. Declarative only —
|
||||
* plain strings the host lays out and escapes; no markup ever reaches the document. */
|
||||
export interface PdfSection {
|
||||
title: string; // section heading (capped at 120 chars)
|
||||
paragraphs?: string[]; // body text — ≤20 paragraphs of ≤2000 chars each
|
||||
table?: { headers: string[]; rows: string[][] }; // simple table — ≤8 headers, ≤50 rows
|
||||
}
|
||||
export interface PdfSectionProvider {
|
||||
/** Return sections to append to a trip's PDF export. Runs with the current user
|
||||
* bound, on a short timeout; the host caps counts/lengths and skips a failing call. */
|
||||
getSections(tripId: number, ctx: PluginContext): Promise<PdfSection[]>;
|
||||
}
|
||||
|
||||
/** One country in an Atlas tint layer. `code` is ISO-3166 alpha-2 (uppercased by the host). */
|
||||
export interface AtlasLayerCountry { code: string; tone?: ContributionTone; label?: string; }
|
||||
/** A country tint layer the host draws over the Atlas world map (wishlists, advisories, …). */
|
||||
export interface AtlasLayer {
|
||||
id: string; // stable per-layer id (React key / dedupe)
|
||||
name?: string; // short layer name
|
||||
countries: AtlasLayerCountry[]; // ≤300 countries per layer
|
||||
}
|
||||
export interface AtlasLayerProvider {
|
||||
/** Return tint layers for the ACTING USER's Atlas map. User-scoped — the host binds
|
||||
* the current user; the hook takes no target parameter. Runs on a short timeout;
|
||||
* the host caps the layer/country counts and skips a failing call. */
|
||||
getLayers(ctx: PluginContext): Promise<AtlasLayer[]>;
|
||||
}
|
||||
|
||||
/** One row of extra info TREK renders under a journal entry (same shape as PlaceDetailItem). */
|
||||
export interface JournalEntryRow { label: string; value?: string; url?: string; }
|
||||
export interface JournalEntryProvider {
|
||||
/** Return rows for a journal entry. Runs with the current user bound, on a short
|
||||
* timeout; the host caps the row count and skips a failing call. */
|
||||
getRows(entryId: number, ctx: PluginContext): Promise<JournalEntryRow[]>;
|
||||
}
|
||||
|
||||
/** One badge the host renders on a dashboard trip card (declarative primitives only). */
|
||||
export interface TripCardContribution {
|
||||
tripId: number; id: string; label: string; value?: string; icon?: string; tone?: ContributionTone; url?: string;
|
||||
}
|
||||
export interface TripCardProvider {
|
||||
/** Return badges for the dashboard trip cards on screen. `tripIds` are those cards
|
||||
* (each access-checked for the acting user). Runs with the current user bound, on a
|
||||
* short timeout; the host caps the badge count and skips a failing call. */
|
||||
getCards(tripIds: number[], ctx: PluginContext): Promise<TripCardContribution[]>;
|
||||
}
|
||||
|
||||
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job).
|
||||
* Needs 'events:subscribe'. */
|
||||
export interface PluginEventSubscription {
|
||||
on: string; // a core event name (e.g. 'place:created', 'day:updated') or '*' for all
|
||||
handler(payload: { event: string; tripId: number }, ctx: PluginContext): Promise<void> | void;
|
||||
// `entity` = the event family (e.g. 'reservation'); `entityId` = WHICH entity changed,
|
||||
// when known. `snapshot` = a whitelisted field view of the changed entity, delivered
|
||||
// ONLY when this plugin also holds the family's db:read:* grant (db:read:trips for
|
||||
// place/day/reservation/accommodation/assignment/trip, db:read:costs for budget,
|
||||
// db:read:packing for packing, db:read:daynotes for dayNote, db:read:files for file).
|
||||
// It never carries user ids, private packing items or secrets; delete/reorder/bulk
|
||||
// events have none. There is still no acting user — a trip read from the handler is
|
||||
// refused, so beyond the snapshot the id tells you what to react to, not what it
|
||||
// contains.
|
||||
handler(payload: { event: string; tripId: number; entity?: string; entityId?: number; snapshot?: Record<string, unknown> }, ctx: PluginContext): Promise<void> | void;
|
||||
}
|
||||
|
||||
/** A function this plugin exposes to its dependents (declared in capabilities.provides). */
|
||||
@@ -171,12 +494,28 @@ export interface PluginDefinition {
|
||||
onUnload?(ctx: PluginContext): Promise<void> | void;
|
||||
routes?: PluginRoute[];
|
||||
jobs?: PluginJob[];
|
||||
/** Handles a callback registered via ctx.scheduler (userless, like a job). The
|
||||
* `name` identifies which scheduled task fired; `payload` is what you passed. */
|
||||
scheduled?(input: { name: string; payload: unknown }, ctx: PluginContext): Promise<void> | void;
|
||||
/** GDPR erasure: a TREK account was deleted — remove everything you hold about it
|
||||
* from your OWN db. Userless (no acting user). Needs `hook:user-data`. The host
|
||||
* calls this durably (queued, retried until it succeeds), so make it idempotent. */
|
||||
deleteUserData?(input: { userId: number }, ctx: PluginContext): Promise<void> | void;
|
||||
/** GDPR portability: return the data you hold about a user (own db only), as a
|
||||
* JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */
|
||||
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
|
||||
events?: PluginEventSubscription[];
|
||||
hooks?: {
|
||||
photoProvider?: PhotoProvider;
|
||||
calendarSource?: CalendarSource;
|
||||
placeDetailProvider?: PlaceDetailProvider;
|
||||
warningProvider?: WarningProvider;
|
||||
tableContributor?: TableContributor;
|
||||
mapMarkerProvider?: MapMarkerProvider;
|
||||
pdfSectionProvider?: PdfSectionProvider;
|
||||
atlasLayerProvider?: AtlasLayerProvider;
|
||||
journalEntryProvider?: JournalEntryProvider;
|
||||
tripCardProvider?: TripCardProvider;
|
||||
};
|
||||
/** Functions exposed to dependents (names must match manifest capabilities.provides). */
|
||||
exports?: Record<string, PluginExport>;
|
||||
@@ -211,22 +550,142 @@ export function createPluginContext(
|
||||
return {
|
||||
id,
|
||||
config: Object.freeze({ ...config }),
|
||||
settings: {
|
||||
get: (key) => (t.rpc('settings.get', { key, _inv: invocationId }) as Promise<{ value: unknown }>).then((r) => r?.value),
|
||||
},
|
||||
db: {
|
||||
query: (sql, ...args) => t.rpc('db.query', { sql, args }) as Promise<never[]>,
|
||||
exec: (sql, ...args) => t.rpc('db.exec', { sql, args }) as Promise<{ changes: number }>,
|
||||
migrate: (mid, sql) => t.rpc('db.migrate', { id: mid, sql }) as Promise<{ applied: boolean }>,
|
||||
tx: (ops) => t.rpc('db.tx', { ops }) as Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>,
|
||||
},
|
||||
trips: {
|
||||
getById: (tripId) => t.rpc('trips.getById', { tripId, _inv: invocationId }),
|
||||
getPlaces: (tripId) => t.rpc('trips.getPlaces', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
getReservations: (tripId) => t.rpc('trips.getReservations', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
getDays: (tripId) => t.rpc('trips.getDays', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
getAccommodations: (tripId) => t.rpc('trips.getAccommodations', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
listMine: () => t.rpc('trips.listMine', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
update: (tripId, input) => t.rpc('trips.update', { tripId, input, _inv: invocationId }),
|
||||
create: (input) => t.rpc('trips.create', { input, _inv: invocationId }),
|
||||
members: (tripId) => t.rpc('trips.members', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
addMember: (tripId, userId) => t.rpc('trips.addMember', { tripId, userId, _inv: invocationId }) as Promise<{ joined: boolean; tripId: number }>,
|
||||
removeMember: (tripId, userId) => t.rpc('trips.removeMember', { tripId, userId, _inv: invocationId }) as Promise<{ removed: boolean }>,
|
||||
},
|
||||
reservations: {
|
||||
listMine: () => t.rpc('reservations.listMine', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
create: (tripId, input) => t.rpc('reservations.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, reservationId, input) => t.rpc('reservations.update', { tripId, reservationId, input, _inv: invocationId }),
|
||||
delete: (tripId, reservationId) => t.rpc('reservations.delete', { tripId, reservationId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
accommodations: {
|
||||
create: (tripId, input) => t.rpc('accommodations.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, accommodationId, input) => t.rpc('accommodations.update', { tripId, accommodationId, input, _inv: invocationId }),
|
||||
delete: (tripId, accommodationId) => t.rpc('accommodations.delete', { tripId, accommodationId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
packing: {
|
||||
list: (tripId) => t.rpc('packing.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
create: (tripId, input) => t.rpc('packing.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, itemId, input) => t.rpc('packing.update', { tripId, itemId, input, _inv: invocationId }),
|
||||
delete: (tripId, itemId) => t.rpc('packing.delete', { tripId, itemId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
listBags: (tripId) => t.rpc('packing.listBags', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
createBag: (tripId, input) => t.rpc('packing.createBag', { tripId, input, _inv: invocationId }),
|
||||
updateBag: (tripId, bagId, input) => t.rpc('packing.updateBag', { tripId, bagId, input, _inv: invocationId }),
|
||||
deleteBag: (tripId, bagId) => t.rpc('packing.deleteBag', { tripId, bagId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
setBagMembers: (tripId, bagId, userIds) => t.rpc('packing.setBagMembers', { tripId, bagId, userIds, _inv: invocationId }),
|
||||
},
|
||||
files: {
|
||||
list: (tripId) => t.rpc('files.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
getContent: (tripId, fileId) => t.rpc('files.getContent', { tripId, fileId, _inv: invocationId }) as Promise<{ name: string; mimetype: string; size: number; content_base64: string }>,
|
||||
create: (tripId, input) => t.rpc('files.create', { tripId, input, _inv: invocationId }),
|
||||
createLink: (tripId, fileId, opts) => t.rpc('files.createLink', { tripId, fileId, opts, _inv: invocationId }),
|
||||
update: (tripId, fileId, input) => t.rpc('files.update', { tripId, fileId, input, _inv: invocationId }),
|
||||
softDelete: (tripId, fileId) => t.rpc('files.softDelete', { tripId, fileId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
collab: {
|
||||
listNotes: (tripId) => t.rpc('collab.listNotes', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
listPolls: (tripId) => t.rpc('collab.listPolls', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
listMessages: (tripId, before) => t.rpc('collab.listMessages', { tripId, before, _inv: invocationId }) as Promise<unknown[]>,
|
||||
createNote: (tripId, input) => t.rpc('collab.createNote', { tripId, input, _inv: invocationId }),
|
||||
createPoll: (tripId, input) => t.rpc('collab.createPoll', { tripId, input, _inv: invocationId }),
|
||||
votePoll: (tripId, pollId, optionIndex) => t.rpc('collab.votePoll', { tripId, pollId, optionIndex, _inv: invocationId }),
|
||||
createMessage: (tripId, text, replyTo) => t.rpc('collab.createMessage', { tripId, text, replyTo, _inv: invocationId }),
|
||||
},
|
||||
notify: {
|
||||
send: (input) => t.rpc('notify.send', { input, _inv: invocationId }) as Promise<{ sent: boolean }>,
|
||||
},
|
||||
ai: {
|
||||
complete: (prompt, system) => t.rpc('ai.complete', { prompt, system, _inv: invocationId }) as Promise<{ text: string }>,
|
||||
extract: (text, jsonSchema, prompt) => t.rpc('ai.extract', { text, jsonSchema, prompt, _inv: invocationId }) as Promise<{ results: Record<string, unknown>[] }>,
|
||||
},
|
||||
oauth: {
|
||||
getAccessToken: () => (t.rpc('oauth.getToken', { _inv: invocationId }) as Promise<{ accessToken: string | null }>).then((r) => r?.accessToken ?? null),
|
||||
},
|
||||
scheduler: {
|
||||
at: (whenMs, name, payload) => t.rpc('scheduler.set', { name, dueAt: whenMs, payload }) as Promise<{ scheduled: boolean }>,
|
||||
in: (ms, name, payload) => t.rpc('scheduler.set', { name, dueAt: Date.now() + ms, payload }) as Promise<{ scheduled: boolean }>,
|
||||
every: (ms, name, payload) => t.rpc('scheduler.set', { name, dueAt: Date.now() + ms, everyMs: ms, payload }) as Promise<{ scheduled: boolean }>,
|
||||
cancel: (name) => t.rpc('scheduler.cancel', { name }) as Promise<{ cancelled: boolean }>,
|
||||
},
|
||||
weather: {
|
||||
get: (lat, lng, date) => t.rpc('weather.get', { lat, lng, date, _inv: invocationId }),
|
||||
},
|
||||
rates: {
|
||||
get: (base) => t.rpc('rates.get', { base, _inv: invocationId }) as Promise<Record<string, number> | null>,
|
||||
},
|
||||
categories: {
|
||||
list: () => t.rpc('categories.list', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
},
|
||||
tags: {
|
||||
list: () => t.rpc('tags.list', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
create: (input) => t.rpc('tags.create', { input, _inv: invocationId }),
|
||||
update: (tagId, input) => t.rpc('tags.update', { tagId, input, _inv: invocationId }),
|
||||
delete: (tagId) => t.rpc('tags.delete', { tagId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
todos: {
|
||||
list: (tripId) => t.rpc('todos.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
create: (tripId, input) => t.rpc('todos.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, todoId, input) => t.rpc('todos.update', { tripId, todoId, input, _inv: invocationId }),
|
||||
delete: (tripId, todoId) => t.rpc('todos.delete', { tripId, todoId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
journal: {
|
||||
listMine: () => t.rpc('journal.listMine', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
getEntries: (journeyId) => t.rpc('journal.getEntries', { journeyId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
createEntry: (journeyId, input) => t.rpc('journal.createEntry', { journeyId, input, _inv: invocationId }),
|
||||
updateEntry: (entryId, input) => t.rpc('journal.updateEntry', { entryId, input, _inv: invocationId }),
|
||||
deleteEntry: (entryId) => t.rpc('journal.deleteEntry', { entryId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
createJourney: (input) => t.rpc('journal.createJourney', { input, _inv: invocationId }),
|
||||
deleteJourney: (journeyId) => t.rpc('journal.deleteJourney', { journeyId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
atlas: {
|
||||
visited: () => t.rpc('atlas.visited', { _inv: invocationId }) as Promise<{ countries: unknown[]; regions: unknown[] }>,
|
||||
bucketList: () => t.rpc('atlas.bucketList', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
markCountry: (code) => t.rpc('atlas.markCountry', { code, _inv: invocationId }),
|
||||
unmarkCountry: (code) => t.rpc('atlas.unmarkCountry', { code, _inv: invocationId }),
|
||||
markRegion: (regionCode, countryCode, regionName) => t.rpc('atlas.markRegion', { regionCode, countryCode, regionName, _inv: invocationId }),
|
||||
unmarkRegion: (regionCode) => t.rpc('atlas.unmarkRegion', { regionCode, _inv: invocationId }),
|
||||
createBucketItem: (input) => t.rpc('atlas.createBucketItem', { input, _inv: invocationId }),
|
||||
deleteBucketItem: (itemId) => t.rpc('atlas.deleteBucketItem', { itemId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
vacay: {
|
||||
mine: () => t.rpc('vacay.mine', { _inv: invocationId }),
|
||||
toggleEntry: (date) => t.rpc('vacay.toggleEntry', { date, _inv: invocationId }) as Promise<{ action: string }>,
|
||||
toggleCompanyHoliday: (date, note) => t.rpc('vacay.toggleCompanyHoliday', { date, note, _inv: invocationId }) as Promise<{ action: string }>,
|
||||
},
|
||||
collections: {
|
||||
listMine: () => t.rpc('collections.listMine', { _inv: invocationId }),
|
||||
get: (id) => t.rpc('collections.get', { id, _inv: invocationId }),
|
||||
create: (input) => t.rpc('collections.create', { input, _inv: invocationId }),
|
||||
update: (id, input) => t.rpc('collections.update', { id, input, _inv: invocationId }),
|
||||
savePlace: (input) => t.rpc('collections.savePlace', { input, _inv: invocationId }),
|
||||
copyToTrip: (input) => t.rpc('collections.copyToTrip', { input, _inv: invocationId }),
|
||||
deletePlace: (placeId) => t.rpc('collections.deletePlace', { placeId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
daynotes: {
|
||||
list: (tripId, dayId) => t.rpc('daynotes.list', { tripId, dayId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
create: (tripId, dayId, input) => t.rpc('daynotes.create', { tripId, dayId, input, _inv: invocationId }),
|
||||
update: (tripId, dayId, noteId, input) => t.rpc('daynotes.update', { tripId, dayId, noteId, input, _inv: invocationId }),
|
||||
delete: (tripId, dayId, noteId) => t.rpc('daynotes.delete', { tripId, dayId, noteId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
costs: {
|
||||
getByTrip: (tripId) => t.rpc('costs.getByTrip', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
@@ -259,11 +718,13 @@ export function createPluginContext(
|
||||
getById: (uid) => t.rpc('users.getById', { id: uid, _inv: invocationId }),
|
||||
},
|
||||
ws: {
|
||||
// _inv binds the invocation's acting user host-side; without it the host can't
|
||||
// membership-check the broadcast and refuses every call (the capability was dead).
|
||||
broadcastToTrip: async (tripId, event, data) => {
|
||||
await t.rpc('ws.broadcastToTrip', { tripId, event, data });
|
||||
await t.rpc('ws.broadcastToTrip', { tripId, event, data, _inv: invocationId });
|
||||
},
|
||||
broadcastToUser: async (userId, event, data) => {
|
||||
await t.rpc('ws.broadcastToUser', { userId, event, data });
|
||||
await t.rpc('ws.broadcastToUser', { userId, event, data, _inv: invocationId });
|
||||
},
|
||||
},
|
||||
log: {
|
||||
@@ -275,7 +736,10 @@ export function createPluginContext(
|
||||
call: (pluginId, fn, args) => t.rpc('plugins.call', { targetId: pluginId, fn, args, _inv: invocationId }),
|
||||
},
|
||||
events: {
|
||||
emit: (name, payload) => { void t.rpc('events.emit', { event: name, payload }); },
|
||||
// Fire-and-forget by contract, but the host CAN reject (undeclared event name,
|
||||
// rate-limit) — swallow it, otherwise the detached rejection crashes the child
|
||||
// and terminally disables the plugin over one bad emit.
|
||||
emit: (name, payload) => { t.rpc('events.emit', { event: name, payload }).catch(() => {}); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { fork, type ChildProcess } from 'node:child_process';
|
||||
import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths';
|
||||
import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope';
|
||||
import type { PluginRpcHost } from '../host/rpc-host';
|
||||
import { scheduleJobs, stopJobs, type ScheduledJob } from '../host/plugin-jobs';
|
||||
import { SNAPSHOT_GRANT, type PluginEventMeta } from '../../../plugin-event-sink';
|
||||
import { RpcRateLimiter, DEFAULT_RPC_LIMIT } from '../host/rate-limit';
|
||||
|
||||
export interface PluginRouteInfo {
|
||||
i: number;
|
||||
@@ -48,15 +51,18 @@ interface Supervised {
|
||||
lastBeat: number;
|
||||
lastRss: number; // last reported resident set size (bytes)
|
||||
routes: PluginRouteInfo[];
|
||||
jobs: string[];
|
||||
jobs: ScheduledJob[]; // declared background jobs (id + cron schedule)
|
||||
jobTasks?: ReturnType<typeof scheduleJobs>; // live node-cron tasks (only when jobs:run granted)
|
||||
hooks: string[]; // provider hooks the plugin implements (e.g. 'placeDetailProvider')
|
||||
events: string[]; // core events the plugin subscribes to (names or '*')
|
||||
exports: string[]; // functions the plugin exposes to dependents (ctx.plugins.call)
|
||||
subscriptions: Array<{ plugin: string; event: string }>; // other-plugin events it listens to
|
||||
pending: Map<string, Pending>; // host→child invokes awaiting a response
|
||||
invocations: Map<string, number | undefined>; // reqId -> acting user of that invoke (undefined = no user, e.g. a job)
|
||||
rpcLimiter: RpcRateLimiter; // caps this plugin's ctx.* call rate + concurrency (host-loop DoS guard)
|
||||
activation?: { resolve: () => void; reject: (e: Error) => void };
|
||||
activationTimer?: ReturnType<typeof setTimeout>; // deadline for the plugin to reach 'active'
|
||||
respawnTimer?: ReturnType<typeof setTimeout>; // crash-backoff restart timer (identity-guarded)
|
||||
}
|
||||
|
||||
export interface SupervisorTuning {
|
||||
@@ -93,12 +99,27 @@ const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
calendarSource: 'hook:calendar-source',
|
||||
placeDetailProvider: 'hook:place-detail-provider',
|
||||
warningProvider: 'hook:trip-warning-provider',
|
||||
tableContributor: 'hook:table-contributor',
|
||||
mapMarkerProvider: 'hook:map-marker-provider',
|
||||
pdfSectionProvider: 'hook:pdf-section-provider',
|
||||
atlasLayerProvider: 'hook:atlas-layer-provider',
|
||||
journalEntryProvider: 'hook:journal-entry-provider',
|
||||
tripCardProvider: 'hook:trip-card-provider',
|
||||
};
|
||||
|
||||
export class PluginSupervisor {
|
||||
private running = new Map<string, Supervised>();
|
||||
private sweep: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly tuning: Required<SupervisorTuning>;
|
||||
// Best-effort redelivery buffer: core events that fire while a subscriber is
|
||||
// mid-restart ('starting'/'error') are held here and replayed once it is active
|
||||
// again — closing the "event lost during the restart window" gap. In-memory only
|
||||
// (no persistence, so no DB writes on the broadcast fast-path), bounded per plugin
|
||||
// and TTL'd. Grants + snapshot gating are re-evaluated at replay time from the
|
||||
// CURRENT grant set, never trusted from when the event was buffered.
|
||||
private readonly pendingEvents = new Map<string, Array<{ tripId: number; event: string; meta?: PluginEventMeta; expiresAt: number }>>();
|
||||
private static readonly EVENT_BUFFER_MAX = 200; // events held per plugin (drop oldest past this)
|
||||
private static readonly EVENT_BUFFER_TTL_MS = 15 * 60_000; // a buffered event older than this is dropped unreplayed
|
||||
|
||||
constructor(
|
||||
private readonly createRpcHost: (id: string, granted: ReadonlySet<string>) => PluginRpcHost,
|
||||
@@ -110,7 +131,16 @@ export class PluginSupervisor {
|
||||
|
||||
/** Spawn a plugin and resolve once it reports `loaded` (or reject on load error). */
|
||||
activate(id: string, granted: ReadonlySet<string>, config: Record<string, unknown> = {}, egress: string[] = []): Promise<void> {
|
||||
if (this.running.has(id)) return Promise.resolve();
|
||||
const existing = this.running.get(id);
|
||||
if (existing) {
|
||||
// A live plugin (starting/active) is idempotent — already activated.
|
||||
if (existing.status === 'starting' || existing.status === 'active') return Promise.resolve();
|
||||
// A DEAD entry (error after a load-failure or crash-auto-disable) otherwise
|
||||
// blocks re-activation forever, so the admin's "enable" button is a silent
|
||||
// no-op after any failure. Drop it and re-spawn fresh — its rpcHost was
|
||||
// already disposed on the way into that state.
|
||||
this.running.delete(id);
|
||||
}
|
||||
const sup: Supervised = {
|
||||
id,
|
||||
granted,
|
||||
@@ -130,28 +160,44 @@ export class PluginSupervisor {
|
||||
subscriptions: [],
|
||||
pending: new Map(),
|
||||
invocations: new Map(),
|
||||
rpcLimiter: new RpcRateLimiter(DEFAULT_RPC_LIMIT, Date.now()),
|
||||
};
|
||||
this.running.set(id, sup);
|
||||
this.ensureSweep();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
sup.activation = { resolve, reject };
|
||||
// Deadline: if the plugin never reports 'loaded' (stuck onLoad), reject the
|
||||
// activation and kill the child rather than hanging + leaking a busy core.
|
||||
sup.activationTimer = setTimeout(async () => {
|
||||
if (sup.status === 'active') return;
|
||||
this.hooks.onLog?.(sup.id, 'error', 'activation timed out; killing');
|
||||
this.setStatus(sup, 'error', 'activation timed out');
|
||||
sup.activation?.reject(new Error('plugin did not finish loading in time'));
|
||||
sup.activation = undefined;
|
||||
this.running.delete(sup.id);
|
||||
// await the kill (SIGTERM grace) before closing the plugin db, so a ctx.*
|
||||
// RPC from the dying child can't hit an already-disposed handle.
|
||||
await this.kill(sup);
|
||||
sup.rpcHost.dispose();
|
||||
}, this.tuning.activationTimeoutMs);
|
||||
sup.activationTimer.unref?.();
|
||||
this.armActivationDeadline(sup);
|
||||
this.spawn(sup);
|
||||
});
|
||||
// Activation can be rejected by a timeout, a load-error, a crash-out, or a shutdown
|
||||
// mid-start. That must reach a caller that awaits activate(), but must NOT crash the
|
||||
// process as an unhandled rejection when a caller fires activate() without awaiting
|
||||
// (a boot reconcile, a test). A no-op terminal handler on a SEPARATE branch marks the
|
||||
// rejection handled; the returned promise still rejects for a real awaiter.
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Kill a plugin that never reaches 'active' within the deadline (a stuck onLoad
|
||||
* would otherwise hang + peg a busy core with no reaper coverage — the reaper only
|
||||
* watches ACTIVE plugins). Used for BOTH the first activation and every crash-respawn,
|
||||
* so a plugin that hangs on load after a crash can't run away. */
|
||||
private armActivationDeadline(sup: Supervised): void {
|
||||
this.clearActivationTimer(sup);
|
||||
sup.activationTimer = setTimeout(async () => {
|
||||
if (sup.status === 'active') return;
|
||||
this.hooks.onLog?.(sup.id, 'error', 'activation timed out; killing');
|
||||
this.setStatus(sup, 'error', 'activation timed out');
|
||||
sup.activation?.reject(new Error('plugin did not finish loading in time'));
|
||||
sup.activation = undefined;
|
||||
this.running.delete(sup.id);
|
||||
this.pendingEvents.delete(sup.id); // don't orphan the buffered-event queue
|
||||
// await the kill (SIGTERM grace) before closing the plugin db, so a ctx.*
|
||||
// RPC from the dying child can't hit an already-disposed handle.
|
||||
await this.kill(sup);
|
||||
sup.rpcHost.dispose();
|
||||
}, this.tuning.activationTimeoutMs);
|
||||
sup.activationTimer.unref?.();
|
||||
}
|
||||
|
||||
private clearActivationTimer(sup: Supervised): void {
|
||||
@@ -159,6 +205,13 @@ export class PluginSupervisor {
|
||||
clearTimeout(sup.activationTimer);
|
||||
sup.activationTimer = undefined;
|
||||
}
|
||||
// The crash-backoff restart timer is cleared at the same lifecycle transitions
|
||||
// (disable / auto-disable / activation-timeout / going active) so a pending restart
|
||||
// can't fire after the entry is gone or replaced. It is also identity-guarded on fire.
|
||||
if (sup.respawnTimer) {
|
||||
clearTimeout(sup.respawnTimer);
|
||||
sup.respawnTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a plugin: ask it to unload, then kill. Idempotent. */
|
||||
@@ -166,6 +219,7 @@ export class PluginSupervisor {
|
||||
const sup = this.running.get(id);
|
||||
if (!sup) return;
|
||||
this.running.delete(id);
|
||||
this.pendingEvents.delete(id); // a deliberately-stopped plugin keeps no buffered events
|
||||
this.clearActivationTimer(sup);
|
||||
this.setStatus(sup, 'stopped');
|
||||
await this.kill(sup);
|
||||
@@ -175,6 +229,14 @@ export class PluginSupervisor {
|
||||
isActive(id: string): boolean {
|
||||
return this.running.get(id)?.status === 'active';
|
||||
}
|
||||
/** The ids of every currently-active plugin — lets a DB sweep scope its window to
|
||||
* plugins that can actually be delivered to, so inactive plugins' rows can't starve
|
||||
* the LIMIT out from under active ones. */
|
||||
activeIds(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [id, sup] of this.running) if (sup.status === 'active') out.push(id);
|
||||
return out;
|
||||
}
|
||||
statusOf(id: string): PluginStatus | null {
|
||||
return this.running.get(id)?.status ?? null;
|
||||
}
|
||||
@@ -217,15 +279,111 @@ export class PluginSupervisor {
|
||||
* Announce a core event to every plugin that subscribed to it (or to '*') AND holds
|
||||
* the 'events:subscribe' grant. Fire-and-forget: the invoke is NOT awaited (a core
|
||||
* broadcast must never block on a plugin) and carries no user (trip reads refused).
|
||||
* Only the event name + tripId are sent — never the payload.
|
||||
* The event name + tripId + a { entity, entityId } hint are sent, plus — ONLY for
|
||||
* a plugin whose granted set includes the family's db:read:* permission — the
|
||||
* whitelisted field snapshot the sink derived. The grant is the authorization
|
||||
* basis (the same fields that grant's read methods return); no acting user is
|
||||
* ever synthesized, so nothing beyond the snapshot is dereferenceable from the
|
||||
* handler.
|
||||
*/
|
||||
deliverEvent(tripId: number, event: string): void {
|
||||
deliverEvent(tripId: number, event: string, meta?: PluginEventMeta): void {
|
||||
for (const [id, sup] of this.running) {
|
||||
if (sup.status !== 'active' || !sup.granted.has('events:subscribe')) continue;
|
||||
if (!sup.granted.has('events:subscribe')) continue;
|
||||
if (!sup.events.includes(event) && !sup.events.includes('*')) continue;
|
||||
this.invoke(id, 'invoke.event', { event, tripId }, { actingUserId: undefined, timeoutMs: 5000 }).catch(() => {
|
||||
/* a subscriber that errors or times out is ignored — events are best-effort */
|
||||
});
|
||||
if (sup.status === 'active') {
|
||||
this.sendEvent(sup, tripId, event, meta);
|
||||
} else if (sup.status === 'starting' || sup.status === 'error') {
|
||||
// Subscribed but mid-restart / recovering: hold the event and replay on activation.
|
||||
this.bufferEvent(id, tripId, event, meta);
|
||||
}
|
||||
// 'stopped' is a deliberate disable — drop, exactly as before.
|
||||
}
|
||||
}
|
||||
|
||||
/** Invoke one active subscriber with an event, applying snapshot gating from its
|
||||
* CURRENT grants. Fire-and-forget: a broadcast must never block on a plugin. */
|
||||
private sendEvent(sup: Supervised, tripId: number, event: string, meta?: PluginEventMeta): void {
|
||||
const { snapshot, ...hint } = meta ?? {};
|
||||
const grant = hint.entity ? SNAPSHOT_GRANT[hint.entity] : undefined;
|
||||
const withSnapshot = snapshot !== undefined && grant !== undefined && sup.granted.has(grant);
|
||||
this.invoke(sup.id, 'invoke.event', { event, tripId, ...hint, ...(withSnapshot ? { snapshot } : {}) }, { actingUserId: undefined, timeoutMs: 5000 }).catch(() => {
|
||||
/* a subscriber that errors or times out is ignored — events are best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
/** Append an event to a subscriber's bounded redelivery buffer (drop-oldest past the cap). */
|
||||
private bufferEvent(id: string, tripId: number, event: string, meta?: PluginEventMeta): void {
|
||||
let q = this.pendingEvents.get(id);
|
||||
if (!q) { q = []; this.pendingEvents.set(id, q); }
|
||||
q.push({ tripId, event, meta, expiresAt: Date.now() + PluginSupervisor.EVENT_BUFFER_TTL_MS });
|
||||
if (q.length > PluginSupervisor.EVENT_BUFFER_MAX) q.splice(0, q.length - PluginSupervisor.EVENT_BUFFER_MAX);
|
||||
}
|
||||
|
||||
/** Replay buffered events to a plugin that just went active. Re-checks the grant +
|
||||
* the current subscription list and drops anything expired, so a grant revoked or a
|
||||
* subscription dropped while the plugin was down never leaks through. */
|
||||
private flushPendingEvents(sup: Supervised): void {
|
||||
const q = this.pendingEvents.get(sup.id);
|
||||
if (!q) return;
|
||||
this.pendingEvents.delete(sup.id);
|
||||
if (!sup.granted.has('events:subscribe')) return;
|
||||
const now = Date.now();
|
||||
for (const item of q) {
|
||||
if (item.expiresAt <= now) continue;
|
||||
if (!sup.events.includes(item.event) && !sup.events.includes('*')) continue;
|
||||
this.sendEvent(sup, item.tripId, item.event, item.meta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a due scheduled task on an active plugin — userless, exactly like a cron
|
||||
* job (no acting user, so trip reads are refused; own db + declared egress only).
|
||||
* No-op if the plugin isn't active; the caller leaves the row so it fires on the
|
||||
* next sweep after reactivation. Fire-and-forget with a job-length timeout.
|
||||
*/
|
||||
deliverScheduled(id: string, name: string, payload: unknown): void {
|
||||
const sup = this.running.get(id);
|
||||
if (!sup || sup.status !== 'active') return;
|
||||
void this.invoke(id, 'invoke.scheduled', { name, payload }, { actingUserId: undefined, timeoutMs: 60_000 }).catch(() => {
|
||||
/* a scheduled task that errors/times out is ignored — best-effort like jobs */
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask an active plugin to erase its own data for a deleted user (GDPR erasure).
|
||||
* Userless — the plugin only learns the userId and acts on its own db. Resolves
|
||||
* true only when the plugin ACKs, which is the caller's signal to drop the durable
|
||||
* queue row; anything else (inactive, no grant, error, timeout) resolves false so
|
||||
* the erasure is retried on a later sweep / after the plugin reactivates.
|
||||
*/
|
||||
async deliverUserErasure(id: string, userId: number): Promise<boolean> {
|
||||
const sup = this.running.get(id);
|
||||
if (!sup || sup.status !== 'active') return false;
|
||||
// NB: no grant re-check here. The row was only enqueued because the plugin held
|
||||
// hook:user-data and may hold the user's data; if it later dropped the grant, the
|
||||
// erasure is still a DUTY, not a gated capability — refusing it would strand the
|
||||
// data forever. deleteUserData is userless and touches only the plugin's own db.
|
||||
try {
|
||||
await this.invoke(id, 'invoke.deleteUserData', { userId }, { actingUserId: undefined, timeoutMs: 30_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect what an active plugin holds about a user (GDPR portability). Userless,
|
||||
* gated by the same hook:user-data grant. Returns the plugin's exported payload,
|
||||
* or undefined if it is inactive, ungranted, doesn't implement the hook, or errors.
|
||||
*/
|
||||
async collectUserExport(id: string, userId: number): Promise<{ ok: true; data: unknown } | { ok: false } | undefined> {
|
||||
const sup = this.running.get(id);
|
||||
if (!sup || sup.status !== 'active' || !sup.granted.has('hook:user-data')) return undefined; // not applicable
|
||||
try {
|
||||
const res = (await this.invoke(id, 'invoke.exportUserData', { userId }, { actingUserId: undefined, timeoutMs: 30_000 })) as { data?: unknown } | undefined;
|
||||
return { ok: true, data: res?.data };
|
||||
} catch {
|
||||
return { ok: false }; // errored/timed out — the caller flags this as incomplete, not "no data"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,9 +425,27 @@ export class PluginSupervisor {
|
||||
if (this.sweep) clearInterval(this.sweep);
|
||||
this.sweep = null;
|
||||
const all = [...this.running.values()];
|
||||
// Mark every entry deliberately stopped and drop it from `running` BEFORE the kills,
|
||||
// so the child 'exit'/'stderr' listeners take the early-return path in onExit instead
|
||||
// of the CRASH path — which would write crash-accounting rows to the DB. That matters
|
||||
// because a restore calls shutdownAll AFTER closeDb(): a crash-path DB write would then
|
||||
// hit the closed connection, throw from an EventEmitter listener, and take the whole
|
||||
// process down mid-restore. (It also stops a normal shutdown from logging phantom
|
||||
// 'crashed' rows + persisting status 'starting'.) Set the field directly, NOT via
|
||||
// setStatus, so no onStatus DB hook fires while the core DB may be closed.
|
||||
for (const s of all) {
|
||||
s.status = 'stopped';
|
||||
this.clearActivationTimer(s);
|
||||
// Fail any in-flight activate() HTTP request instead of leaving it hung forever.
|
||||
s.activation?.reject(new Error('plugin host shutting down'));
|
||||
s.activation = undefined;
|
||||
}
|
||||
this.running.clear();
|
||||
// Drop every buffered event: after a live restore these carry pre-restore tripIds/
|
||||
// snapshots, and replaying them into the restored data on a re-activation would be wrong.
|
||||
this.pendingEvents.clear();
|
||||
await Promise.all(all.map((s) => this.kill(s)));
|
||||
for (const s of all) s.rpcHost.dispose();
|
||||
this.running.clear();
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────────────
|
||||
@@ -313,10 +489,24 @@ export class PluginSupervisor {
|
||||
// acting user comes from the invocation the child is currently handling
|
||||
// (its `_inv` reqId → our invocation map), NOT from anything the plugin can
|
||||
// set in the call params.
|
||||
const inv = (msg as RpcRequest).params as { _inv?: unknown } | undefined;
|
||||
const req = msg as RpcRequest;
|
||||
// Rate limit BEFORE dispatch: every ctx.* call runs synchronously on the host
|
||||
// thread (better-sqlite3 + the router), so an unthrottled `while (true)` loop
|
||||
// in a plugin freezes the whole instance — including this supervisor's reap
|
||||
// sweep. A throttled call is refused with HOST_ERROR (retryable) rather than
|
||||
// executed; a legitimate plugin never hits the generous burst.
|
||||
if (!sup.rpcLimiter.tryAcquire(Date.now())) {
|
||||
sup.child?.send({ k: 'res', id: req.id, ok: false, error: { code: 'HOST_ERROR', message: 'rate limit exceeded — slow down ctx.* calls' } } satisfies RpcError);
|
||||
return;
|
||||
}
|
||||
const inv = req.params as { _inv?: unknown } | undefined;
|
||||
const actingUserId = typeof inv?._inv === 'string' ? sup.invocations.get(inv._inv) : undefined;
|
||||
const res = await sup.rpcHost.dispatch(msg as RpcRequest, actingUserId);
|
||||
sup.child?.send(res);
|
||||
try {
|
||||
const res = await sup.rpcHost.dispatch(req, actingUserId);
|
||||
sup.child?.send(res);
|
||||
} finally {
|
||||
sup.rpcLimiter.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -344,16 +534,21 @@ export class PluginSupervisor {
|
||||
break;
|
||||
}
|
||||
case 'loaded': {
|
||||
// Ignore a duplicate/forged `loaded` after activation — a plugin must
|
||||
// not be able to re-register its route table once it is live.
|
||||
if (sup.status === 'active') break;
|
||||
// Only a still-starting entry that is still THE current one may go active.
|
||||
// A late `loaded` (child finished onLoad during the kill grace, or the IPC
|
||||
// buffer delivered it after 'exit') must not resurrect a stopped/removed
|
||||
// plugin or re-register routes on an active one — otherwise its jobs get
|
||||
// scheduled against a dead/replaced supervisor and fire twice after re-enable.
|
||||
if (this.running.get(sup.id) !== sup || sup.status !== 'starting') break;
|
||||
sup.lastBeat = Date.now();
|
||||
const d = msg.data as {
|
||||
routes?: PluginRouteInfo[]; jobs?: string[]; hooks?: string[]; events?: string[];
|
||||
routes?: PluginRouteInfo[]; jobs?: ScheduledJob[]; hooks?: string[]; events?: string[];
|
||||
exports?: string[]; subscriptions?: Array<{ plugin: string; event: string }>;
|
||||
};
|
||||
sup.routes = d.routes ?? [];
|
||||
sup.jobs = d.jobs ?? [];
|
||||
sup.jobs = Array.isArray(d.jobs)
|
||||
? d.jobs.filter((j): j is ScheduledJob => !!j && typeof j.id === 'string' && typeof j.schedule === 'string')
|
||||
: [];
|
||||
sup.hooks = d.hooks ?? [];
|
||||
sup.events = d.events ?? [];
|
||||
sup.exports = Array.isArray(d.exports) ? d.exports.filter((e): e is string => typeof e === 'string') : [];
|
||||
@@ -362,12 +557,28 @@ export class PluginSupervisor {
|
||||
: [];
|
||||
this.clearActivationTimer(sup);
|
||||
this.setStatus(sup, 'active');
|
||||
// Start the plugin's background jobs (opt-in via jobs:run). A job carries no
|
||||
// acting user, so its trip reads are refused; it can only touch its own db /
|
||||
// egress. Wrapped so a scheduling hiccup can never break activation.
|
||||
try {
|
||||
sup.jobTasks = scheduleJobs(sup.granted, sup.jobs, (jobId) => {
|
||||
void this.invoke(sup.id, 'invoke.job', { jobId }, { actingUserId: undefined, timeoutMs: 60_000 }).catch(() => {});
|
||||
});
|
||||
} catch {
|
||||
/* a scheduler error must never stop a plugin from going live */
|
||||
}
|
||||
// Replay any events that fired while the plugin was (re)starting.
|
||||
this.flushPendingEvents(sup);
|
||||
sup.activation?.resolve();
|
||||
sup.activation = undefined;
|
||||
break;
|
||||
}
|
||||
case 'load-error': {
|
||||
const message = (msg.data as { message?: string })?.message || 'plugin load failed';
|
||||
// Same guard as `loaded`: a late load-error from a child that was already
|
||||
// stopped/replaced must not overwrite the current entry's status or re-dispose
|
||||
// a handle the disable path already tore down.
|
||||
if (this.running.get(sup.id) !== sup || sup.status !== 'starting') break;
|
||||
this.clearActivationTimer(sup);
|
||||
this.setStatus(sup, 'error', message);
|
||||
sup.activation?.reject(new Error(message));
|
||||
@@ -406,12 +617,20 @@ export class PluginSupervisor {
|
||||
sup.child = null;
|
||||
// In-flight host→child invokes can never complete now.
|
||||
this.rejectPending(sup, 'plugin exited');
|
||||
// The dead child's node-cron tasks keep ticking (node-cron holds them, not the
|
||||
// child). Stop them here — otherwise every crash-restart cycle leaks a task-set
|
||||
// AND re-schedules a fresh one, so the job fires N+1 times per tick after N
|
||||
// crashes (duplicate egress/db side effects). kill() already does this for the
|
||||
// clean-stop path; the crash/auto-disable path reaches this instead.
|
||||
stopJobs(sup.jobTasks);
|
||||
sup.jobTasks = undefined;
|
||||
// A clean stop we asked for isn't a crash.
|
||||
if (sup.status === 'stopped' || sup.status === 'error') return;
|
||||
if (!this.running.has(sup.id)) return;
|
||||
|
||||
sup.crashes.push(Date.now());
|
||||
const recent = sup.crashes.filter((t) => t > Date.now() - this.tuning.crashWindowMs);
|
||||
sup.crashes = recent; // trim: drop timestamps outside the window so the array can't grow unbounded
|
||||
if (recent.length >= this.tuning.crashLimit) {
|
||||
this.clearActivationTimer(sup);
|
||||
this.setStatus(sup, 'error', `auto-disabled after ${recent.length} crashes`);
|
||||
@@ -422,11 +641,25 @@ export class PluginSupervisor {
|
||||
}
|
||||
const delay = Math.min(this.tuning.backoffCapMs, 1000 * 2 ** (recent.length - 1));
|
||||
this.hooks.onLog?.(sup.id, 'warn', `crashed (code=${code} sig=${signal}); restarting in ${delay}ms`);
|
||||
// Cancel the previous activation deadline: a crash late in the activation budget
|
||||
// leaves it armed, and if it fired during this backoff wait it would mark the entry
|
||||
// 'error' and silently cancel the scheduled retry. (Clears the respawnTimer slot too,
|
||||
// which we set immediately below.)
|
||||
this.clearActivationTimer(sup);
|
||||
this.setStatus(sup, 'starting');
|
||||
const timer = setTimeout(() => {
|
||||
if (this.running.has(sup.id)) this.spawn(sup);
|
||||
sup.respawnTimer = setTimeout(() => {
|
||||
// Identity + status check, not just presence: a disable + re-enable in the backoff
|
||||
// window replaces `running[id]` with a NEW sup, so `running.has(id)` would still be
|
||||
// true and this stale timer would respawn a GHOST child from the old entry. Only
|
||||
// respawn when the entry is still THIS one and still awaiting its restart.
|
||||
if (this.running.get(sup.id) !== sup || sup.status !== 'starting') return;
|
||||
this.spawn(sup);
|
||||
// A respawn needs the SAME activation deadline as a first activation — otherwise a
|
||||
// plugin that hangs in onLoad after a crash sits in 'starting' forever, pegging a
|
||||
// core and buffering events that never flush (the reaper ignores non-active).
|
||||
this.armActivationDeadline(sup);
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
sup.respawnTimer.unref?.();
|
||||
}
|
||||
|
||||
private ensureSweep(): void {
|
||||
@@ -440,6 +673,13 @@ export class PluginSupervisor {
|
||||
* ceiling (drives the crash/backoff path, so a repeat offender auto-disables).
|
||||
*/
|
||||
reapStale(now = Date.now()): void {
|
||||
// Expire buffered events whose TTL passed without a flush (a subscriber stuck in
|
||||
// 'error'/'starting' would otherwise pin its queue until it happens to reactivate).
|
||||
for (const [id, q] of this.pendingEvents) {
|
||||
const live = q.filter((e) => e.expiresAt > now);
|
||||
if (live.length === 0) this.pendingEvents.delete(id);
|
||||
else if (live.length !== q.length) this.pendingEvents.set(id, live);
|
||||
}
|
||||
for (const sup of this.running.values()) {
|
||||
if (sup.status !== 'active') continue;
|
||||
const rss = this.childRss(sup);
|
||||
@@ -479,6 +719,10 @@ export class PluginSupervisor {
|
||||
}
|
||||
|
||||
private async kill(sup: Supervised): Promise<void> {
|
||||
// Stop the plugin's cron jobs first so no tick fires against a dying/gone child
|
||||
// (and nothing leaks across a deactivate/reactivate).
|
||||
stopJobs(sup.jobTasks);
|
||||
sup.jobTasks = undefined;
|
||||
const child = sup.child;
|
||||
if (!child) return;
|
||||
sup.child = null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* TREK renders its UI with a single icon language (lucide) and no emojis. Plugin
|
||||
* authors — especially AI-generated ones — tend to sprinkle emojis into the declarative
|
||||
* text TREK renders NATIVELY (badges, columns, warnings, PDF sections, map-marker labels,
|
||||
* calendar/photo titles, notifications), which clashes with that language. So every such
|
||||
* string is emoji-stripped at the render boundary: no matter what a plugin returns, the
|
||||
* text TREK draws in its own chrome stays emoji-free. A plugin that wants an icon uses
|
||||
* the declarative `icon` field with a lucide name instead. This does NOT touch the
|
||||
* plugin's own sandboxed `/ui` frame — that markup is the author's to design.
|
||||
*/
|
||||
|
||||
// Colour emoji + the pieces of emoji SEQUENCES: skin-tone modifiers, regional-
|
||||
// indicator flag letters, ZWJ joiners, variation selectors, keycap enclosure and
|
||||
// tag chars. Emoji_Presentation (not the far broader Extended_Pictographic) targets
|
||||
// only the glyphs that render as colour emoji by default, so legitimate text symbols
|
||||
// a plugin might use — ©, ®, ™, ★, arrows, card suits — survive; removing the
|
||||
// joiners/selectors keeps a compound sequence from leaving stray glue behind. Each
|
||||
// escape is an intentional, independent code point to delete — the
|
||||
// no-misleading-character-class rule assumes a class means to combine them, which
|
||||
// is the opposite of what we want here.
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
const EMOJI_RE = /\p{Emoji_Presentation}|\p{Emoji_Modifier}|\p{Regional_Indicator}|[\u200D\uFE00-\uFE0F\u20E3\u{E0020}-\u{E007F}]/gu;
|
||||
|
||||
/** Remove emojis from a display string and tidy the whitespace they leave behind. */
|
||||
export function stripEmoji(s: string): string {
|
||||
const stripped = s.replace(EMOJI_RE, '');
|
||||
// Nothing removed → return verbatim so a plain string keeps its exact spacing and
|
||||
// paragraph breaks (matters for PDF paragraphs). Otherwise collapse only the
|
||||
// horizontal gaps an emoji left, never newlines.
|
||||
if (stripped === s) return s;
|
||||
return stripped.replace(/[^\S\r\n]{2,}/g, ' ').replace(/ +$/gm, '').trim();
|
||||
}
|
||||
|
||||
/** True if the string contains at least one emoji — used by the dev/validate warnings. */
|
||||
export function hasEmoji(s: string): boolean {
|
||||
// EMOJI_RE carries the `g` flag, so .test() advances lastIndex; reset it first
|
||||
// or a second call could resume mid-string and miss a leading emoji.
|
||||
EMOJI_RE.lastIndex = 0;
|
||||
return EMOJI_RE.test(s);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/trip-card-contributions?tripIds=1,2,3 — host-rendered badges that plugins
|
||||
* implementing the `tripCardProvider` hook (#plugins) add to the dashboard trip cards.
|
||||
* The dashboard passes exactly the cards it is showing; the host access-checks each
|
||||
* one for the acting user before any plugin sees it, then fans out to every granted
|
||||
* provider ONCE with the full id list (a card grid can hold many trips — one call per
|
||||
* plugin, not one per trip).
|
||||
*
|
||||
* Like view-contributions, every field is NORMALIZED server-side: a contribution only
|
||||
* ever crosses this boundary as bounded primitives (never HTML/markup). Strings are
|
||||
* String()-coerced + length-capped, tone/icon are enum/length-bounded, the count is
|
||||
* capped, and a url must be http/https/mailto — a javascript:/data: url would be
|
||||
* click-XSS into the dashboard DOM. Additive + fail-safe: a slow/failing provider
|
||||
* contributes nothing.
|
||||
*/
|
||||
type Tone = 'default' | 'success' | 'warn' | 'danger';
|
||||
interface TripCardBadge { pluginId: string; tripId: number; id: string; label: string; value?: string; icon?: string; tone: Tone; url?: string; }
|
||||
|
||||
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
|
||||
const MAX_TRIP_IDS = 60; // a dashboard never shows more cards than this
|
||||
const MAX_BADGES_PER_TRIP = 4; // per provider PER card — so one badge on every card always fits
|
||||
const MAX_BADGES_TOTAL = MAX_TRIP_IDS * MAX_BADGES_PER_TRIP; // overall abuse bound
|
||||
const LABEL_MAX = 64;
|
||||
const VALUE_MAX = 256;
|
||||
const URL_MAX = 2048;
|
||||
const ID_MAX = 64;
|
||||
const ICON_MAX = 40;
|
||||
|
||||
const cap = (v: unknown, max: number): string => stripEmoji(String(v ?? '')).slice(0, max);
|
||||
|
||||
/** Allow only http/https/mailto; anything else (or an unparseable value) is dropped —
|
||||
* a javascript:/data:/vbscript: URL rendered as an <a href> is click-XSS. */
|
||||
function safeUrl(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw.length === 0 || raw.length > URL_MAX) return undefined;
|
||||
try {
|
||||
const proto = new URL(raw).protocol;
|
||||
return proto === 'http:' || proto === 'https:' || proto === 'mailto:' ? raw : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce a provider's raw output to bounded badges, keeping only badges whose tripId
|
||||
* is one the acting user actually asked about (a provider can't inject onto a card the
|
||||
* dashboard isn't showing / the user can't access). */
|
||||
function normalize(pluginId: string, raw: unknown, allowed: Set<number>): TripCardBadge[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: TripCardBadge[] = [];
|
||||
const perTrip = new Map<number, number>(); // cap PER card, not across the whole grid
|
||||
for (const c of list) {
|
||||
if (out.length >= MAX_BADGES_TOTAL) break;
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
const tripId = typeof c.tripId === 'number' && Number.isFinite(c.tripId) ? c.tripId : undefined;
|
||||
const id = cap(c.id, ID_MAX);
|
||||
const label = cap(c.label, LABEL_MAX);
|
||||
if (tripId === undefined || !allowed.has(tripId) || !id || !label) continue;
|
||||
const n = perTrip.get(tripId) ?? 0;
|
||||
if (n >= MAX_BADGES_PER_TRIP) continue; // this card is full; other cards still get theirs
|
||||
perTrip.set(tripId, n + 1);
|
||||
out.push({
|
||||
pluginId,
|
||||
tripId,
|
||||
id,
|
||||
label,
|
||||
value: c.value !== undefined && c.value !== null ? cap(c.value, VALUE_MAX) : undefined,
|
||||
icon: typeof c.icon === 'string' && c.icon ? cap(c.icon, ICON_MAX) : undefined,
|
||||
tone: (TONES.has(c.tone as string) ? c.tone : 'default') as Tone,
|
||||
url: safeUrl(c.url),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/trip-card-contributions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TripCardContributionsController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get()
|
||||
async get(
|
||||
@Query('tripIds') tripIdsRaw: string | undefined,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ contributions: TripCardBadge[] }> {
|
||||
if (!pluginsEnabled()) return { contributions: [] };
|
||||
const userId = req.user?.id;
|
||||
if (userId == null) return { contributions: [] };
|
||||
|
||||
// Parse + access-check the requested cards; only those the user can actually see
|
||||
// are handed to any plugin.
|
||||
const requested = (tripIdsRaw ?? '')
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n > 0)
|
||||
.slice(0, MAX_TRIP_IDS);
|
||||
const accessible = [...new Set(requested)].filter((id) => canAccessTrip(id, userId));
|
||||
if (accessible.length === 0) return { contributions: [] };
|
||||
const allowed = new Set(accessible);
|
||||
|
||||
const ids = this.runtime.providersOf('tripCardProvider');
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<TripCardBadge[]> => {
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(id, 'tripCardProvider', 'getCards', [accessible], userId, 5000);
|
||||
return normalize(id, raw, allowed);
|
||||
} catch {
|
||||
return []; // a slow / failing provider contributes nothing
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { contributions: perProvider.flat() };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/trip-warnings/:tripId — validation/warning contributions from plugins
|
||||
@@ -20,6 +21,9 @@ interface Warning {
|
||||
placeId?: number;
|
||||
}
|
||||
|
||||
const MAX_WARNINGS = 20; // per provider — bounds the banner
|
||||
const MESSAGE_MAX = 300;
|
||||
|
||||
@Controller('api/trip-warnings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TripWarningsController {
|
||||
@@ -41,10 +45,13 @@ export class TripWarningsController {
|
||||
try {
|
||||
const raw = (await this.runtime.invokeHook(id, 'warningProvider', 'getWarnings', [tripId], userId, 5000)) as unknown;
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
return list.map((w) => ({
|
||||
// Drop non-object elements BEFORE the cap — otherwise one null in the array
|
||||
// throws inside map() and the catch below discards ALL of this provider's
|
||||
// warnings (and the cap should count only valid entries anyway).
|
||||
return list.filter((w): w is Record<string, unknown> => !!w && typeof w === 'object').slice(0, MAX_WARNINGS).map((w) => ({
|
||||
pluginId: id,
|
||||
level: w.level === 'error' || w.level === 'info' ? (w.level as Level) : 'warning',
|
||||
message: String(w.message ?? ''),
|
||||
message: stripEmoji(String(w.message ?? '')).slice(0, MESSAGE_MAX),
|
||||
dayId: typeof w.dayId === 'number' ? w.dayId : undefined,
|
||||
placeId: typeof w.placeId === 'number' ? w.placeId : undefined,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/view-contributions/:view/:tripId — host-rendered columns/actions that
|
||||
* plugins implementing the `tableContributor` hook (#plugins) add into a native
|
||||
* planner view (reservations/transports/places/day/costs/packing/files/todos). Mirrors place-details.controller (#1429):
|
||||
* additive + fail-safe — the caller must access the trip, each provider runs
|
||||
* host->plugin on a short timeout, and one that errors/times out contributes nothing.
|
||||
*
|
||||
* Unlike place-details, every field is NORMALIZED server-side: a plugin contribution
|
||||
* only ever crosses this boundary as bounded primitives (never HTML/markup). Strings
|
||||
* are String()-coerced + length-capped, kind/tone/icon/target are enum-whitelisted,
|
||||
* per-provider counts are capped, and a column URL must be http/https/mailto — a
|
||||
* javascript:/data: URL would be click-XSS into the native DOM.
|
||||
*/
|
||||
type Tone = 'default' | 'success' | 'warn' | 'danger';
|
||||
type ActionTarget = { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string };
|
||||
interface Column { kind: 'column'; pluginId: string; entityId: number; id: string; label: string; value?: string; url?: string; icon?: string; tone: Tone; }
|
||||
interface Action { kind: 'action'; pluginId: string; entityId: number; id: string; label: string; icon?: string; target: ActionTarget; }
|
||||
type Contribution = Column | Action;
|
||||
|
||||
const VIEWS: ReadonlySet<string> = new Set(['reservations', 'transports', 'places', 'day', 'costs', 'packing', 'files', 'todos']);
|
||||
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
|
||||
const MAX_COLUMNS = 20;
|
||||
const MAX_ACTIONS = 10;
|
||||
const LABEL_MAX = 64;
|
||||
const VALUE_MAX = 256;
|
||||
const URL_MAX = 2048;
|
||||
const ID_MAX = 64;
|
||||
const ICON_MAX = 40;
|
||||
const SUB_MAX = 512;
|
||||
|
||||
const cap = (v: unknown, max: number): string => stripEmoji(String(v ?? '')).slice(0, max);
|
||||
|
||||
/** Allow only http/https/mailto; anything else (or an unparseable value) is dropped —
|
||||
* a javascript:/data:/vbscript: URL rendered as an <a href> is click-XSS. */
|
||||
function safeUrl(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || raw.length === 0 || raw.length > URL_MAX) return undefined;
|
||||
try {
|
||||
const proto = new URL(raw).protocol;
|
||||
return proto === 'http:' || proto === 'https:' || proto === 'mailto:' ? raw : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(pluginId: string, raw: unknown): Contribution[] {
|
||||
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
|
||||
const out: Contribution[] = [];
|
||||
// The caps are PER ENTITY (the data model is keyed by entityId) — a global cap would
|
||||
// make columns vanish from every row past the first MAX_COLUMNS in a large table. A
|
||||
// generous overall bound still guards against a provider returning millions of rows.
|
||||
const colsPer = new Map<number, number>();
|
||||
const actsPer = new Map<number, number>();
|
||||
const MAX_TOTAL = 5000;
|
||||
for (const c of list) {
|
||||
if (out.length >= MAX_TOTAL) break;
|
||||
if (!c || typeof c !== 'object') continue;
|
||||
const entityId = typeof c.entityId === 'number' && Number.isFinite(c.entityId) ? c.entityId : undefined;
|
||||
const id = cap(c.id, ID_MAX);
|
||||
const label = cap(c.label, LABEL_MAX);
|
||||
if (entityId === undefined || !id || !label) continue; // an entity-keyed contribution must have all three
|
||||
const icon = typeof c.icon === 'string' && c.icon ? cap(c.icon, ICON_MAX) : undefined;
|
||||
|
||||
if (c.kind === 'column') {
|
||||
if ((colsPer.get(entityId) ?? 0) >= MAX_COLUMNS) continue;
|
||||
colsPer.set(entityId, (colsPer.get(entityId) ?? 0) + 1);
|
||||
out.push({
|
||||
kind: 'column',
|
||||
pluginId,
|
||||
entityId,
|
||||
id,
|
||||
label,
|
||||
value: c.value !== undefined && c.value !== null ? cap(c.value, VALUE_MAX) : undefined,
|
||||
url: safeUrl(c.url),
|
||||
icon,
|
||||
tone: (TONES.has(c.tone as string) ? c.tone : 'default') as Tone,
|
||||
});
|
||||
} else if (c.kind === 'action') {
|
||||
if ((actsPer.get(entityId) ?? 0) >= MAX_ACTIONS) continue;
|
||||
const t = c.target as Record<string, unknown> | undefined;
|
||||
let target: ActionTarget | undefined;
|
||||
if (t && t.kind === 'frame' && typeof t.sub === 'string' && t.sub) {
|
||||
target = { kind: 'frame', sub: cap(t.sub, SUB_MAX) };
|
||||
} else if (t && t.kind === 'route' && (t.method === 'GET' || t.method === 'POST') && typeof t.sub === 'string' && t.sub) {
|
||||
target = { kind: 'route', method: t.method, sub: cap(t.sub, SUB_MAX) };
|
||||
}
|
||||
if (!target) continue; // a malformed target drops the action, never renders a dead button
|
||||
actsPer.set(entityId, (actsPer.get(entityId) ?? 0) + 1);
|
||||
out.push({ kind: 'action', pluginId, entityId, id, label, icon, target });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Controller('api/view-contributions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ViewContributionsController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get(':view/:tripId')
|
||||
async get(
|
||||
@Param('view') view: string,
|
||||
@Param('tripId') tripIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ contributions: Contribution[] }> {
|
||||
if (!pluginsEnabled() || !VIEWS.has(view)) return { contributions: [] };
|
||||
const tripId = Number(tripIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(tripId) || userId == null || !canAccessTrip(tripId, userId)) return { contributions: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('tableContributor');
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<Contribution[]> => {
|
||||
try {
|
||||
const raw = await this.runtime.invokeHook(id, 'tableContributor', 'getContributions', [view, tripId], userId, 5000);
|
||||
return normalize(id, raw);
|
||||
} catch {
|
||||
return []; // a slow / failing provider contributes nothing
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { contributions: perProvider.flat() };
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,139 @@
|
||||
// and so tests that mock `./websocket` don't accidentally strip the sink. websocket
|
||||
// calls emitPluginEvent for every CORE broadcast; the plugin runtime registers the
|
||||
// sink in onModuleInit. Name-only + best-effort by design (see PluginSupervisor).
|
||||
let sink: ((tripId: number, event: string) => void) | null = null;
|
||||
|
||||
export function setPluginEventSink(fn: ((tripId: number, event: string) => void) | null): void {
|
||||
/** What a subscribed plugin learns about a core event beyond its name: the entity
|
||||
* family, WHICH entity changed when known, and — for plugins holding the matching
|
||||
* db:read:* grant (filtered per plugin at deliver time, see SNAPSHOT_GRANT) — a
|
||||
* whitelisted field snapshot of the changed entity. Never a user: the handler still
|
||||
* runs with no acting user, and the snapshot whitelist carries no user ids and no
|
||||
* secrets by construction. */
|
||||
export interface PluginEventMeta {
|
||||
entity?: string;
|
||||
entityId?: number;
|
||||
snapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let sink: ((tripId: number, event: string, meta?: PluginEventMeta) => void) | null = null;
|
||||
|
||||
export function setPluginEventSink(fn: ((tripId: number, event: string, meta?: PluginEventMeta) => void) | null): void {
|
||||
sink = fn;
|
||||
}
|
||||
|
||||
export function emitPluginEvent(tripId: number, event: string): void {
|
||||
export function emitPluginEvent(tripId: number, event: string, meta?: PluginEventMeta): void {
|
||||
if (!sink) return;
|
||||
try {
|
||||
sink(tripId, event);
|
||||
sink(tripId, event, meta);
|
||||
} catch {
|
||||
/* a plugin sink must never break a core broadcast */
|
||||
}
|
||||
}
|
||||
|
||||
// The entity id lives at DIFFERENT payload keys per event family, so we key an
|
||||
// EXPLICIT whitelist by family (`place:created` -> 'place') and try only its known
|
||||
// id paths — never a "first id-looking field". This is what keeps a non-entity id
|
||||
// from leaking: e.g. `budget:member-paid-updated` carries { itemId, userId } and we
|
||||
// deliberately only read itemId, never userId. Families not listed (settlement/bag
|
||||
// sub-entities, reorders, bulk position updates) yield no entityId, which is correct.
|
||||
const ENTITY_ID_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
place: ['placeId', 'place.id'],
|
||||
day: ['dayId', 'day.id'],
|
||||
reservation: ['reservationId', 'reservation.id'],
|
||||
accommodation: ['accommodationId', 'accommodation.id'],
|
||||
budget: ['itemId', 'item.id'],
|
||||
packing: ['itemId', 'item.id'],
|
||||
dayNote: ['noteId', 'note.id'],
|
||||
file: ['fileId', 'file.id'],
|
||||
assignment: ['assignmentId', 'assignment.id'],
|
||||
trip: ['id', 'trip.id'], // trip:deleted carries { id }, trip:updated carries { trip }
|
||||
};
|
||||
|
||||
// Which db:read:* grant entitles a plugin to an event family's snapshot. The
|
||||
// supervisor filters PER PLUGIN at deliver time: no grant, no snapshot — the
|
||||
// plugin then gets exactly the old {entity, entityId} hint.
|
||||
export const SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
|
||||
place: 'db:read:trips',
|
||||
day: 'db:read:trips',
|
||||
reservation: 'db:read:trips',
|
||||
accommodation: 'db:read:trips',
|
||||
assignment: 'db:read:trips',
|
||||
trip: 'db:read:trips',
|
||||
budget: 'db:read:costs',
|
||||
packing: 'db:read:packing',
|
||||
dayNote: 'db:read:daynotes',
|
||||
file: 'db:read:files',
|
||||
};
|
||||
|
||||
// Per-family snapshot whitelist: which payload key holds the changed entity and
|
||||
// which of its fields may travel. EXPLICIT allowlists, like ENTITY_ID_KEYS — a
|
||||
// field not named here never surfaces, so user ids (owner_id/paid_by/uploaded_by/
|
||||
// participants/members), trips.feed_token and whatever a migration adds later stay
|
||||
// out by construction. Delete events carry no object -> no snapshot, correct.
|
||||
const ENTITY_SNAPSHOT: Readonly<Record<string, { key: string; fields: readonly string[] }>> = {
|
||||
place: { key: 'place', fields: ['id', 'trip_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency', 'place_time', 'end_time', 'duration_minutes', 'notes', 'image_url', 'website', 'phone', 'transport_mode'] },
|
||||
day: { key: 'day', fields: ['id', 'trip_id', 'day_number', 'date', 'title', 'notes'] },
|
||||
reservation: { key: 'reservation', fields: ['id', 'trip_id', 'day_id', 'end_day_id', 'place_id', 'assignment_id', 'title', 'accommodation_id', 'reservation_time', 'reservation_end_time', 'location', 'confirmation_number', 'notes', 'status', 'type', 'needs_review', 'endpoints'] },
|
||||
accommodation: { key: 'accommodation', fields: ['id', 'trip_id', 'place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out', 'confirmation', 'notes'] },
|
||||
budget: { key: 'item', fields: ['id', 'trip_id', 'category', 'name', 'total_price', 'persons', 'days', 'note', 'sort_order'] },
|
||||
packing: { key: 'item', fields: ['id', 'trip_id', 'name', 'checked', 'category', 'sort_order'] },
|
||||
dayNote: { key: 'note', fields: ['id', 'day_id', 'trip_id', 'text', 'time', 'icon', 'sort_order'] },
|
||||
file: { key: 'file', fields: ['id', 'trip_id', 'place_id', 'reservation_id', 'original_name', 'file_size', 'mime_type', 'description'] },
|
||||
assignment: { key: 'assignment', fields: ['id', 'day_id', 'place_id', 'order_index', 'notes', 'assignment_time', 'assignment_end_time'] },
|
||||
trip: { key: 'trip', fields: ['id', 'title', 'description', 'start_date', 'end_date', 'currency', 'cover_image', 'is_archived', 'reminder_days'] },
|
||||
};
|
||||
|
||||
/** Pick the whitelisted fields of an event's entity object, or undefined when the
|
||||
* payload carries none (deletes, reorders, bulk ops) — never throws. A PRIVATE
|
||||
* packing item (#858) yields no snapshot at all: its core broadcast is scoped to
|
||||
* the owner's sockets, and a snapshot would hand it to every subscribed plugin. */
|
||||
function entitySnapshot(entity: string, payload: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const spec = ENTITY_SNAPSHOT[entity];
|
||||
if (!spec || !payload || typeof payload !== 'object') return undefined;
|
||||
const obj = payload[spec.key];
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return undefined;
|
||||
const row = obj as Record<string, unknown>;
|
||||
if (entity === 'packing' && row.is_private) return undefined;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of spec.fields) {
|
||||
if (row[f] !== undefined) out[f] = row[f];
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function readPath(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!path.includes('.')) return obj[path];
|
||||
const [head, tail] = path.split('.');
|
||||
const sub = obj[head];
|
||||
return sub && typeof sub === 'object' ? (sub as Record<string, unknown>)[tail] : undefined;
|
||||
}
|
||||
|
||||
function toEntityId(v: unknown): number | undefined {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE + SYNCHRONOUS. Derive the {entity, entityId, snapshot} a core event carries
|
||||
* for subscribed plugins. `entity` is the event family (before ':'); `entityId`
|
||||
* comes ONLY from the per-family whitelist above, so a non-entity id can never
|
||||
* surface; `snapshot` are the whitelisted fields of the changed entity — the
|
||||
* supervisor strips it for plugins without the family's db:read:* grant.
|
||||
* Must never throw — it runs inside the core broadcast fast-path.
|
||||
*/
|
||||
export function pluginEventMeta(eventType: string, payload: Record<string, unknown>): PluginEventMeta | undefined {
|
||||
const entity = eventType.split(':')[0];
|
||||
if (!entity) return undefined;
|
||||
const snapshot = entitySnapshot(entity, payload);
|
||||
const keys = ENTITY_ID_KEYS[entity];
|
||||
if (keys && payload && typeof payload === 'object') {
|
||||
for (const k of keys) {
|
||||
const id = toEntityId(readPath(payload, k));
|
||||
if (id !== undefined) return snapshot ? { entity, entityId: id, snapshot } : { entity, entityId: id };
|
||||
}
|
||||
}
|
||||
return snapshot ? { entity, snapshot } : { entity };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// A tiny, dependency-free relay so the core account-deletion path can notify the
|
||||
// plugin runtime that a user was erased — without the auth/admin services (plain
|
||||
// modules) importing the NestJS plugins layer. Mirrors plugin-event-sink.ts: the
|
||||
// runtime registers the sink in onModuleInit; the core services call
|
||||
// emitUserDeleted AFTER the user row is gone. Best-effort + never throws by design
|
||||
// (the plugin side persists the erasure so nothing is lost if the sink is absent
|
||||
// or the runtime is mid-boot).
|
||||
|
||||
let sink: ((userId: number) => void) | null = null;
|
||||
|
||||
export function setUserDeletedSink(fn: ((userId: number) => void) | null): void {
|
||||
sink = fn;
|
||||
}
|
||||
|
||||
/** Announce that a TREK account was fully deleted. Called by the core deletion
|
||||
* paths after deleteUserCompletely so plugins can erase their own per-user data.
|
||||
* Swallows everything: a plugin bookkeeping error must never fail the deletion. */
|
||||
export function emitUserDeleted(userId: number): void {
|
||||
if (!sink) return;
|
||||
try {
|
||||
sink(userId);
|
||||
} catch {
|
||||
/* the erasure is enqueued transactionally on the plugin side; a sink hiccup
|
||||
is non-fatal and the next runtime boot reconciles from the queue */
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
@@ -193,6 +194,7 @@ export function deleteUser(id: string, currentUserId: number) {
|
||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
||||
|
||||
deleteUserCompletely(userToDel.id);
|
||||
emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
|
||||
return { email: userToDel.email };
|
||||
}
|
||||
|
||||
|
||||
@@ -82,46 +82,25 @@ setInterval(() => {
|
||||
|
||||
// ── Bounding-box lookup tables ──────────────────────────────────────────────
|
||||
|
||||
export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
||||
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
||||
BA:[15.7,42.6,19.6,45.3],BD:[88.0,20.7,92.7,26.6],BF:[-5.5,9.4,2.4,15.1],BH:[50.4,25.8,50.7,26.2],BI:[29.0,-4.5,30.8,-2.3],
|
||||
BJ:[0.8,6.2,3.8,12.4],BN:[114.1,4.0,115.4,5.1],BO:[-69.7,-22.9,-57.5,-9.7],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],
|
||||
BG:[22.4,41.2,28.6,44.2],BW:[20.0,-26.9,29.4,-17.8],CA:[-141,41.7,-52.6,83.1],CD:[12.2,-13.5,31.3,5.4],CG:[11.2,-5.0,18.7,3.7],
|
||||
CI:[-8.6,4.3,-2.5,10.7],CL:[-75.6,-55.9,-66.9,-17.5],CM:[8.4,1.7,16.2,13.1],CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],
|
||||
CR:[-85.9,8.0,-82.5,11.2],CU:[-85.0,19.8,-74.1,23.2],CV:[-25.4,14.8,-22.7,17.2],CY:[32.3,34.5,34.1,35.7],HR:[13.5,42.4,19.5,46.6],
|
||||
CZ:[12.1,48.6,18.9,51.1],DJ:[41.8,11.0,43.4,12.7],DK:[8,54.6,15.2,57.8],DO:[-72.0,17.5,-68.3,19.9],EC:[-81.0,-5.0,-75.2,1.5],
|
||||
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],ER:[36.4,12.4,43.1,18.0],ET:[33.0,3.4,47.9,14.9],FI:[20.6,59.8,31.6,70.1],
|
||||
FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],GE:[40.0,41.0,46.7,43.6],GH:[-3.3,4.7,1.2,11.2],GN:[-15.1,7.2,-7.6,12.7],
|
||||
GR:[19.4,34.8,29.7,41.8],GT:[-92.2,13.7,-88.2,17.8],HN:[-89.4,12.9,-83.2,16.5],HT:[-74.5,18.0,-71.6,20.1],HU:[16,45.7,22.9,48.6],
|
||||
IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],
|
||||
IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],JM:[-78.4,17.7,-76.2,18.5],JO:[34.9,29.2,39.3,33.4],
|
||||
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KG:[69.2,39.2,80.3,43.2],KH:[102.3,10.4,107.6,14.7],KR:[126,33.2,129.6,38.6],
|
||||
KW:[46.5,28.5,48.4,30.1],KZ:[50.3,40.6,87.4,55.4],LA:[100.1,13.9,107.7,22.5],LB:[35.1,33.1,36.6,34.7],LK:[79.7,5.9,81.9,9.8],
|
||||
LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],LU:[5.7,49.4,6.5,50.2],LY:[9.5,19.5,25.2,33.3],MA:[-13.2,27.7,-1,35.9],
|
||||
MD:[26.6,45.5,30.2,48.5],ME:[18.4,41.8,20.4,43.6],MG:[43.2,-25.6,50.5,-11.9],MK:[20.5,40.8,23.0,42.4],ML:[-4.8,10.1,4.3,25.0],
|
||||
MM:[92.2,9.8,101.2,28.5],MN:[87.8,41.6,119.9,52.1],MR:[-17.1,14.7,-4.8,27.3],MT:[14.1,35.8,14.6,36.1],MU:[57.3,-20.5,57.8,-19.9],
|
||||
MV:[72.7,-0.7,73.8,7.1],MW:[32.7,-17.1,35.9,-9.4],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MZ:[30.2,-26.9,40.8,-10.5],
|
||||
NA:[11.7,-28.9,25.3,-17.0],NE:[0.2,11.7,15.9,23.5],NI:[-87.7,10.7,-83.1,15.0],NL:[3.4,50.8,7.2,53.5],NP:[80.1,26.4,88.2,30.4],
|
||||
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],OM:[51.9,16.6,59.8,26.4],PA:[-83.0,7.2,-77.2,9.6],PG:[140.8,-11.7,155.7,-1.3],
|
||||
PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],PL:[14.1,49,24.1,54.9],PS:[34.2,29.5,35.6,32.6],
|
||||
PT:[-9.5,36.8,-6.2,42.2],PY:[-62.6,-27.6,-54.3,-19.3],QA:[50.7,24.5,51.6,26.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],
|
||||
RW:[29.0,-2.8,30.9,-1.0],SA:[34.6,16.4,55.7,32.2],SC:[55.3,-9.7,55.8,-3.7],SD:[21.8,3.4,38.6,22.2],SG:[103.6,1.2,104.1,1.5],
|
||||
SI:[13.4,45.4,16.6,46.9],SK:[16.8,47.7,22.6,49.6],SN:[-17.5,12.3,-11.4,15.0],SO:[40.9,-1.7,51.4,11.9],RS:[18.8,42.2,23,46.2],
|
||||
SV:[-90.1,13.2,-87.7,14.5],SY:[35.7,32.3,42.4,37.3],TG:[-0.2,6.1,1.8,11.2],TJ:[67.3,36.7,75.2,41.0],TM:[52.4,35.1,66.7,42.8],
|
||||
TN:[7.5,30.2,11.6,37.5],TT:[-61.9,10.0,-60.5,11.3],TW:[120.1,21.9,122.0,25.3],TZ:[29.3,-11.7,40.4,-1.0],ZA:[16.5,-34.8,32.9,-22.1],
|
||||
SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
|
||||
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
|
||||
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
|
||||
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
|
||||
// Territories with their own ISO code that sit inside a larger country's box.
|
||||
// Listed so getCountryFromCoords()'s smallest-box match picks them over the host
|
||||
// (e.g. Hong Kong/Macau over China, San Marino/Vatican over Italy).
|
||||
HK:[113.83,22.15,114.43,22.56],MO:[113.53,22.10,113.60,22.21],SM:[12.40,43.89,12.52,43.99],
|
||||
VA:[12.44,41.90,12.46,41.91],MC:[7.40,43.72,7.44,43.75],LI:[9.47,47.05,9.64,47.27],
|
||||
// Territories that have their own ISO code but no admin0 polygon in the bundle.
|
||||
// Without a polygon they can't be point-in-polygon tested, so they rely purely on
|
||||
// their box and win via the smallest-box tie-break in getCountryFromCoords()
|
||||
// (e.g. Hong Kong/Macau over China, Gibraltar over Spain).
|
||||
const MICRO_TERRITORY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
HK:[113.83,22.15,114.43,22.56],MO:[113.53,22.10,113.60,22.21],
|
||||
GI:[-5.36,36.11,-5.33,36.16],PR:[-67.30,17.88,-65.22,18.53],
|
||||
PS:[34.2,29.5,35.6,32.6],XK:[20.0,41.9,21.8,43.3],
|
||||
};
|
||||
|
||||
// A polygon-less micro-territory box only auto-wins the smallest-box tie-break when it is
|
||||
// TIGHT around the enclave. HK(0.25°²), MO(0.008), GI(0.0015) and PR(1.35) hug their
|
||||
// territory, so a point inside them really is in that territory. PS(4.34) and XK(2.52) are
|
||||
// loose regional rectangles that sprawl across a sovereign neighbour (PS over Israel, XK
|
||||
// over North Macedonia) — a point there usually belongs to the neighbour, so those boxes
|
||||
// must NOT auto-win; they defer to the neighbour's real polygon first (see #1490-class fix
|
||||
// below). This threshold sits between PR and XK.
|
||||
const MICRO_BOX_MAX_AREA = 2.0;
|
||||
|
||||
export const NAME_TO_CODE: Record<string, string> = {
|
||||
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
|
||||
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
|
||||
@@ -228,45 +207,108 @@ function pointInGeometry(lng: number, lat: number, geom: { type: string; coordin
|
||||
return false;
|
||||
}
|
||||
|
||||
// ISO_A2 → admin0 geometry, built once. Micro-territories (HK, MO, SM, VA, …) aren't
|
||||
// in admin0, so they stay absent and keep the smallest-box behaviour below.
|
||||
let countryPolyIndex: Map<string, { type: string; coordinates: number[][][] | number[][][][] }> | null = null;
|
||||
function getCountryPolyIndex(): Map<string, { type: string; coordinates: number[][][] | number[][][][] }> {
|
||||
if (countryPolyIndex) return countryPolyIndex;
|
||||
const idx = new Map<string, { type: string; coordinates: number[][][] | number[][][][] }>();
|
||||
type Geometry = { type: string; coordinates: number[][][] | number[][][][] };
|
||||
type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||
|
||||
// ISO_A2 → admin0 geometry + bounding boxes, both derived from the bundled admin0
|
||||
// borders on first use and cached.
|
||||
//
|
||||
// The boxes used to be a hand-maintained table, which drifted: 43 countries (NG, BY,
|
||||
// GL, KP, TD, SS, …) had no box at all, so their coordinates fell into a *neighbour's*
|
||||
// box instead and resolved to the wrong country — Lagos came out as Benin, Minsk as
|
||||
// Russia (#1490). Deriving them from the same polygons we already ship keeps the two
|
||||
// in lockstep and can't drift again.
|
||||
//
|
||||
// One box is stored PER GEOMETRY PART, not per country. A single box around a country
|
||||
// that straddles the antimeridian (RU, US, FJ, KI) would span nearly the whole globe;
|
||||
// per-part boxes keep Alaska and Chukotka separate and handle the ±180 wrap for free.
|
||||
let countryPolyIndex: Map<string, Geometry> | null = null;
|
||||
let countryBoxIndex: Map<string, Box[]> | null = null;
|
||||
|
||||
function buildCountryIndexes(): void {
|
||||
const polys = new Map<string, Geometry>();
|
||||
const boxes = new Map<string, Box[]>();
|
||||
|
||||
for (const f of loadGeoBundle('admin0').features ?? []) {
|
||||
const code = f.properties?.ISO_A2;
|
||||
if (code && code !== '-99' && f.geometry) idx.set(String(code).toUpperCase(), f.geometry);
|
||||
const raw = f.properties?.ISO_A2;
|
||||
if (!raw || raw === '-99' || !f.geometry) continue;
|
||||
const code = String(raw).toUpperCase();
|
||||
polys.set(code, f.geometry);
|
||||
|
||||
const parts = (f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates) as number[][][][];
|
||||
const codeBoxes = boxes.get(code) ?? [];
|
||||
for (const part of parts) {
|
||||
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
|
||||
for (const [lng, lat] of part[0]) {
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
codeBoxes.push([minLng, minLat, maxLng, maxLat]);
|
||||
}
|
||||
boxes.set(code, codeBoxes);
|
||||
}
|
||||
countryPolyIndex = idx;
|
||||
return idx;
|
||||
|
||||
// Micro-territories aren't in admin0 — give them their box, but no polygon.
|
||||
for (const [code, box] of Object.entries(MICRO_TERRITORY_BOXES)) {
|
||||
if (!boxes.has(code)) boxes.set(code, [box]);
|
||||
}
|
||||
|
||||
countryPolyIndex = polys;
|
||||
countryBoxIndex = boxes;
|
||||
}
|
||||
|
||||
function getCountryPolyIndex(): Map<string, Geometry> {
|
||||
if (!countryPolyIndex) buildCountryIndexes();
|
||||
return countryPolyIndex!;
|
||||
}
|
||||
|
||||
function getCountryBoxIndex(): Map<string, Box[]> {
|
||||
if (!countryBoxIndex) buildCountryIndexes();
|
||||
return countryBoxIndex!;
|
||||
}
|
||||
|
||||
export function getCountryFromCoords(lat: number, lng: number): string | null {
|
||||
// Cheap prefilter: every country whose bounding box contains the point.
|
||||
// Cheap prefilter: every country with a part-box containing the point. Keep the
|
||||
// area of the matching part so overlapping candidates can be ranked below.
|
||||
const candidates: { code: string; area: number }[] = [];
|
||||
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
|
||||
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
||||
candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) });
|
||||
for (const [code, boxes] of getCountryBoxIndex()) {
|
||||
for (const [minLng, minLat, maxLng, maxLat] of boxes) {
|
||||
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
||||
candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0].code;
|
||||
|
||||
// Boxes overlap near borders, so a single point can sit in several — picking the
|
||||
// smallest box then mis-assigns a point just across the border (#1331). Disambiguate
|
||||
// with the real admin0 polygon: try candidates smallest-box-first and return the one
|
||||
// whose polygon actually contains the point. A candidate with no admin0 polygon (a
|
||||
// micro-territory like HK/MO/SM/VA) keeps the smallest-box win.
|
||||
// Boxes overlap near borders, so a point can sit in several — picking the smallest
|
||||
// box alone mis-assigns a point just across the border (#1331). Disambiguate with
|
||||
// the real admin0 polygon: try candidates smallest-box-first and return the one whose
|
||||
// polygon actually contains the point. A candidate with no polygon (a micro-territory
|
||||
// like HK/MO/GI) keeps the smallest-box win — but only when its box is tight enough to
|
||||
// trust (MICRO_BOX_MAX_AREA); a loose regional box (PS/XK) defers to a real neighbour
|
||||
// polygon so it can't steal a point that lies inside that sovereign (Tel Aviv → IL,
|
||||
// Skopje → MK), while a genuine PS/XK point still lands on the deferred box below.
|
||||
//
|
||||
// This runs even for a lone candidate. Short-circuiting a single match was what let a
|
||||
// point resolve to a country whose polygon plainly excludes it (#1490).
|
||||
candidates.sort((a, b) => a.area - b.area);
|
||||
const polys = getCountryPolyIndex();
|
||||
for (const { code } of candidates) {
|
||||
let looseBoxFallback: string | null = null;
|
||||
for (const { code, area } of candidates) {
|
||||
const poly = polys.get(code);
|
||||
if (!poly) return code;
|
||||
if (!poly) {
|
||||
if (area <= MICRO_BOX_MAX_AREA) return code;
|
||||
if (looseBoxFallback === null) looseBoxFallback = code;
|
||||
continue;
|
||||
}
|
||||
if (pointInGeometry(lng, lat, poly)) return code;
|
||||
}
|
||||
// No polygon contained the point (coastal slop / data gap) — fall back to smallest box.
|
||||
return candidates[0].code;
|
||||
// No tight micro-box and no polygon contained the point — prefer a deferred loose box
|
||||
// (a real PS/XK point), else fall back to the smallest box (coastal slop / data gap).
|
||||
return looseBoxFallback ?? candidates[0].code;
|
||||
}
|
||||
|
||||
export function getCountryFromAddress(address: string | null): string | null {
|
||||
@@ -374,8 +416,11 @@ export async function getStats(userId: number) {
|
||||
const tripIds = trips.map(t => t.id);
|
||||
|
||||
if (tripIds.length === 0) {
|
||||
const hiddenOnly = getHiddenCountries(userId);
|
||||
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
||||
const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
|
||||
const countries = manualCountries
|
||||
.filter(mc => !hiddenOnly.has(mc.country_code))
|
||||
.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
|
||||
return { countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } };
|
||||
}
|
||||
|
||||
@@ -436,9 +481,15 @@ export async function getStats(userId: number) {
|
||||
}
|
||||
const totalCities = citySet.size;
|
||||
|
||||
// Countries the user explicitly removed. Only the zero-count passes below are
|
||||
// suppressed — a country with real places isn't removable in the UI anyway, and once
|
||||
// the user adds a place there the tombstone should stop mattering (#1490).
|
||||
const hidden = getHiddenCountries(userId);
|
||||
|
||||
// Merge manually marked countries
|
||||
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
||||
for (const mc of manualCountries) {
|
||||
if (hidden.has(mc.country_code)) continue;
|
||||
if (!countries.find(c => c.code === mc.country_code)) {
|
||||
countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
|
||||
}
|
||||
@@ -447,15 +498,17 @@ export async function getStats(userId: number) {
|
||||
// Merge countries reached only by a transport booking. Those store geocoded from/to
|
||||
// coordinates in reservation_endpoints but create no place row, so they never show up
|
||||
// via resolvePlaceCountries above and would otherwise be missed (#1366).
|
||||
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited.
|
||||
const endpoints = db.prepare(`
|
||||
SELECT DISTINCT e.lat, e.lng
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')})
|
||||
WHERE r.trip_id IN (${tripIds.map(() => '?').join(',')}) AND e.role IN ('from', 'to')
|
||||
`).all(...tripIds) as { lat: number; lng: number }[];
|
||||
for (const e of endpoints) {
|
||||
const code = getCountryFromCoords(e.lat, e.lng);
|
||||
if (code && !countries.find(c => c.code === code)) {
|
||||
if (code && !hidden.has(code) && !countries.find(c => c.code === code)) {
|
||||
countries.push({ code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
|
||||
}
|
||||
}
|
||||
@@ -552,13 +605,25 @@ export function listVisitedCountries(userId: number): { country_code: string; cr
|
||||
).all(userId) as { country_code: string; created_at: string }[];
|
||||
}
|
||||
|
||||
/** Countries the user explicitly removed, which getStats must not re-derive (#1490). */
|
||||
export function getHiddenCountries(userId: number): Set<string> {
|
||||
const rows = db.prepare('SELECT country_code FROM hidden_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
||||
return new Set(rows.map(r => r.country_code));
|
||||
}
|
||||
|
||||
export function markCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
|
||||
// Marking it visited again lifts a previous removal.
|
||||
db.prepare('DELETE FROM hidden_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
}
|
||||
|
||||
export function unmarkCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
// A country derived from a place or a transport endpoint has no visited_countries row,
|
||||
// so the deletes above are no-ops and getStats would re-derive it on the next request.
|
||||
// Tombstone it so the removal actually sticks (#1490).
|
||||
db.prepare('INSERT OR IGNORE INTO hidden_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
|
||||
}
|
||||
|
||||
// ── Mark / unmark region ────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,8 +16,9 @@ import { createEphemeralToken } from './ephemeralTokens';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { startTripReminders } from '../scheduler';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
||||
import { getFlightDistanceKm } from './distanceService';
|
||||
import { getCountryFromCoords } from './atlasService';
|
||||
import { getCountryFromCoords, getHiddenCountries } from './atlasService';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { User } from '../types';
|
||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
@@ -606,6 +607,7 @@ export function deleteAccount(userId: number, userEmail: string, userRole: strin
|
||||
}
|
||||
}
|
||||
deleteUserCompletely(userId);
|
||||
emitUserDeleted(userId); // let plugins erase their own per-user data
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -999,19 +1001,25 @@ export function getTravelStats(userId: number) {
|
||||
// reached place_regions — a country reached only by a flight/train (no lodging or
|
||||
// planned place there) was never counted as visited (#1366). Resolve each endpoint
|
||||
// coordinate to a country and fold it in too.
|
||||
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
|
||||
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
|
||||
const endpoints = db.prepare(`
|
||||
SELECT DISTINCT e.lat, e.lng
|
||||
FROM reservation_endpoints e
|
||||
JOIN reservations r ON e.reservation_id = r.id
|
||||
JOIN trips t ON r.trip_id = t.id
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?) AND e.role IN ('from', 'to')
|
||||
`).all(userId, userId) as { lat: number; lng: number }[];
|
||||
for (const e of endpoints) {
|
||||
const code = getCountryFromCoords(e.lat, e.lng);
|
||||
if (code) countryCodes.add(code.toUpperCase());
|
||||
}
|
||||
|
||||
// Countries the user removed in Atlas stay removed on the dashboard too, so the
|
||||
// passport card and the Atlas map agree (#1490).
|
||||
for (const code of getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase());
|
||||
|
||||
return {
|
||||
countries: [...countryCodes],
|
||||
cities: [...cities],
|
||||
|
||||
@@ -6,6 +6,9 @@ import Database from 'better-sqlite3';
|
||||
import { db, closeDb, reinitialize } from '../db/database';
|
||||
import * as scheduler from '../scheduler';
|
||||
import { invalidatePermissionsCache } from './permissions';
|
||||
import { pluginsCodeRoot, pluginsDataRoot } from '../nest/plugins/paths';
|
||||
import { stageExtractedPluginTrees, applyStagedRestoreNow } from '../nest/plugins/plugin-backup';
|
||||
import { snapshotAllPluginDataDbs } from '../nest/plugins/host/plugin-data.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
@@ -31,8 +34,22 @@ if (rawBackupUploadLimit) {
|
||||
}
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed
|
||||
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
||||
// limit only caps the compressed bytes). Generous enough for any real backup.
|
||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||
// limit only caps the compressed bytes). Default 5 GB, raisable via
|
||||
// BACKUP_MAX_DECOMPRESSED_MB for an instance whose own backups (now including the
|
||||
// plugin trees) legitimately grow past it — otherwise its own backups become
|
||||
// unrestorable. Invalid values warn and fall back to the default.
|
||||
const DEFAULT_BACKUP_DECOMPRESSED_MB = 5 * 1024;
|
||||
const rawDecompressedLimit = process.env.BACKUP_MAX_DECOMPRESSED_MB?.trim();
|
||||
let backupDecompressedMb = DEFAULT_BACKUP_DECOMPRESSED_MB;
|
||||
if (rawDecompressedLimit) {
|
||||
const parsed = Number(rawDecompressedLimit);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
backupDecompressedMb = parsed;
|
||||
} else {
|
||||
console.warn(`BACKUP_MAX_DECOMPRESSED_MB="${rawDecompressedLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_DECOMPRESSED_MB} MB.`);
|
||||
}
|
||||
}
|
||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = backupDecompressedMb * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -151,6 +168,7 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `backup-${timestamp}.zip`;
|
||||
const outputPath = path.join(backupsDir, filename);
|
||||
const pdataSnap = path.join(backupsDir, `.plugins-snap-${timestamp}`);
|
||||
|
||||
try {
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
||||
@@ -203,6 +221,37 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin data — each plugin's own SQLite file and any blobs. This is the ONLY
|
||||
// copy of the user data a plugin holds, so it belongs in the backup. Checkpoint
|
||||
// every open handle first (the host keeps them open in WAL mode) so the archived
|
||||
// .db files are complete snapshots and not missing recent commits stranded in a
|
||||
// -wal sidecar — the same treatment travel.db gets above.
|
||||
const pdata = pluginsDataRoot();
|
||||
if (fs.existsSync(pdata)) {
|
||||
// Archive a consistent point-in-time snapshot, not the live files: the archiver
|
||||
// reads lazily while streaming, so a plugin writing during the backup (an auto-
|
||||
// checkpoint landing mid-read) would otherwise put a torn .db + out-of-sync -wal
|
||||
// into the zip — the plugin's ONLY data copy, silently corrupt. This VACUUM-INTOs
|
||||
// each open db and drops the sidecars; the snap dir is removed in the finally.
|
||||
snapshotAllPluginDataDbs(pdataSnap);
|
||||
archive.directory(pdataSnap, 'plugins-data');
|
||||
}
|
||||
// Plugin code — so a restore is self-contained (the `plugins` rows reference it).
|
||||
// Dev-links (a plugin dir symlinked/junctioned to an author's source) are skipped
|
||||
// by realpath: we never bundle a linked source tree from outside the code root.
|
||||
const pcode = pluginsCodeRoot();
|
||||
if (fs.existsSync(pcode)) {
|
||||
const realRoot = fs.realpathSync(pcode);
|
||||
for (const entry of fs.readdirSync(pcode)) {
|
||||
const dir = path.join(pcode, entry);
|
||||
let real: string;
|
||||
try { real = fs.realpathSync(dir); } catch { continue; }
|
||||
if (!real.startsWith(realRoot + path.sep)) continue; // dev-link points outside → skip
|
||||
try { if (!fs.statSync(dir).isDirectory()) continue; } catch { continue; }
|
||||
archive.directory(dir, `plugins-code/${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
@@ -217,6 +266,10 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
console.error('Backup error:', err);
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
throw err;
|
||||
} finally {
|
||||
// The snapshot was a staging copy for the archiver only; drop it once streaming is done
|
||||
// (the await above resolves on the output stream's 'close', so the archive is complete).
|
||||
fs.rmSync(pdataSnap, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,17 +287,54 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
|
||||
let reinitFailed: unknown = null;
|
||||
try {
|
||||
// Check the declared uncompressed size from the central directory and bail
|
||||
// if it exceeds the cap, before extracting anything.
|
||||
// Fast reject on the central-directory's declared size, then extract entry-by-entry
|
||||
// enforcing the ACTUAL decompressed bytes. The declared uncompressedSize is
|
||||
// attacker-declarable — a zip bomb can under-report it and expand past the cap during
|
||||
// extraction — so the real guard counts bytes as they are written and aborts once the
|
||||
// running total crosses the cap. Each entry's resolved path is also confined to
|
||||
// extractDir (a `../` entry that escaped the root — zip-slip — is refused).
|
||||
const directory = await unzipper.Open.file(zipPath);
|
||||
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
|
||||
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
|
||||
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
|
||||
}
|
||||
|
||||
await fs.createReadStream(zipPath)
|
||||
.pipe(unzipper.Extract({ path: extractDir }))
|
||||
.promise();
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
let decompressedBytes = 0;
|
||||
for (const entry of directory.files) {
|
||||
if (entry.type === 'Directory') continue;
|
||||
const dest = path.join(extractDir, entry.path);
|
||||
const rel = path.relative(extractDir, dest);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
return { success: false, error: 'Invalid backup: an entry path escapes the archive root.', status: 400 };
|
||||
}
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const source = entry.stream();
|
||||
const out = fs.createWriteStream(dest);
|
||||
source.on('data', (chunk: Buffer) => {
|
||||
decompressedBytes += chunk.length;
|
||||
if (decompressedBytes > MAX_BACKUP_DECOMPRESSED_SIZE) {
|
||||
source.destroy();
|
||||
out.destroy();
|
||||
reject(new Error('DECOMPRESSED_CAP_EXCEEDED'));
|
||||
}
|
||||
});
|
||||
source.on('error', reject);
|
||||
out.on('error', reject);
|
||||
out.on('finish', resolve);
|
||||
source.pipe(out);
|
||||
});
|
||||
} catch (err) {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
if (err instanceof Error && err.message === 'DECOMPRESSED_CAP_EXCEEDED') {
|
||||
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const extractedDb = path.join(extractDir, 'travel.db');
|
||||
if (!fs.existsSync(extractedDb)) {
|
||||
@@ -316,6 +406,23 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
|
||||
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Plugin trees can't be swapped while the runtime holds their DBs open, so stage
|
||||
// them beside the live trees, then ask the runtime to quiesce its plugins and apply
|
||||
// the swap NOW. If the runtime isn't up (plugins disabled / restore during boot),
|
||||
// the staging waits for the boot reconcile — with nothing running, no data diverges.
|
||||
// Best-effort: a staging error must not fail an otherwise-good core restore.
|
||||
try {
|
||||
stageExtractedPluginTrees(extractDir);
|
||||
// Quiesce regardless of whether trees were staged: the restored travel.db carries
|
||||
// a different `plugins` table, so any plugin still running with its pre-restore
|
||||
// identity/grants is now a ghost — invisible in the restored UI, unstoppable short
|
||||
// of a process restart. applyStagedRestoreNow closes those handles; the tree swap
|
||||
// it also performs is a no-op when nothing was staged (e.g. an older archive).
|
||||
await applyStagedRestoreNow();
|
||||
} catch (e) {
|
||||
console.error('Restore: staging plugin trees failed:', e);
|
||||
}
|
||||
} finally {
|
||||
// Reopening the DB must always run (even if the copy above threw) so the
|
||||
// process is never left without a connection. Capture a reopen failure
|
||||
|
||||
@@ -465,7 +465,7 @@ export function calculateSettlement(
|
||||
|
||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
|
||||
@@ -195,8 +195,12 @@ export function getFormattedNoteById(noteId: string | number) {
|
||||
return formatNote(note);
|
||||
}
|
||||
|
||||
export function deleteNoteFile(noteId: string | number, fileId: string | number): boolean {
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, noteId) as TripFile | undefined;
|
||||
export function deleteNoteFile(tripId: string | number, noteId: string | number, fileId: string | number): boolean {
|
||||
// Scope to the trip — like every sibling collab op — so a caller authorized for THEIR
|
||||
// trip can't delete a note-file that belongs to someone else's trip (IDOR). trip_files
|
||||
// carries trip_id, so this ties the deleted object to the URL's :tripId the controller
|
||||
// access-checked, not just to a note/file id an attacker can enumerate.
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ? AND trip_id = ?').get(fileId, noteId, tripId) as TripFile | undefined;
|
||||
if (!file) return false;
|
||||
|
||||
const filePath = path.join(__dirname, '../../uploads', file.filename);
|
||||
|
||||
@@ -173,7 +173,14 @@ export function deleteDay(id: string | number) {
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function addDays(date: string, n: number): string {
|
||||
/**
|
||||
* Add `n` days to a YYYY-MM-DD date string, staying entirely in UTC.
|
||||
*
|
||||
* Deliberately never builds a local-time Date: `new Date('2026-06-07T00:00:00')`
|
||||
* parses as *server-local* midnight, so a later .toISOString() round-trips through
|
||||
* UTC and lands on the previous day whenever the server sits east of Greenwich.
|
||||
*/
|
||||
export function addDays(date: string, n: number): string {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY;
|
||||
const dt = new Date(t);
|
||||
|
||||
@@ -136,18 +136,23 @@ export function createJourney(
|
||||
|
||||
// link trips and sync skeleton entries
|
||||
if (data.trip_ids?.length) {
|
||||
// Track the first trip that was ACTUALLY linked (addTripToJourney access-checks and
|
||||
// returns false for a foreign/inaccessible trip). Inheriting the cover from a raw
|
||||
// trip_ids[0] would otherwise leak an arbitrary trip's cover image cross-tenant.
|
||||
let coverTripId: number | undefined;
|
||||
for (const tripId of data.trip_ids) {
|
||||
addTripToJourney(journeyId, tripId, userId);
|
||||
if (addTripToJourney(journeyId, tripId, userId) && coverTripId === undefined) coverTripId = tripId;
|
||||
}
|
||||
|
||||
// inherit cover image from first selected trip
|
||||
const firstTrip = db.prepare('SELECT cover_image FROM trips WHERE id = ?').get(data.trip_ids[0]) as
|
||||
| { cover_image: string | null }
|
||||
| undefined;
|
||||
if (firstTrip?.cover_image) {
|
||||
// trip stores full path (/uploads/covers/x.jpg), journey stores relative (covers/x.jpg)
|
||||
const relativePath = firstTrip.cover_image.replace(/^\/uploads\//, '');
|
||||
db.prepare('UPDATE journeys SET cover_image = ? WHERE id = ?').run(relativePath, journeyId);
|
||||
if (coverTripId !== undefined) {
|
||||
const firstTrip = db.prepare('SELECT cover_image FROM trips WHERE id = ?').get(coverTripId) as
|
||||
| { cover_image: string | null }
|
||||
| undefined;
|
||||
if (firstTrip?.cover_image) {
|
||||
// trip stores full path (/uploads/covers/x.jpg), journey stores relative (covers/x.jpg)
|
||||
const relativePath = firstTrip.cover_image.replace(/^\/uploads\//, '');
|
||||
db.prepare('UPDATE journeys SET cover_image = ? WHERE id = ?').run(relativePath, journeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,26 +403,20 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||
dateMaxOrder.set(entryDate, nextOrder);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
insertSkeletonEntry({
|
||||
journeyId,
|
||||
tripId,
|
||||
place.id,
|
||||
placeId: place.id,
|
||||
authorId,
|
||||
place.name,
|
||||
title: place.name,
|
||||
entryDate,
|
||||
entryTime,
|
||||
place.address || place.name,
|
||||
place.lat || null,
|
||||
place.lng || null,
|
||||
nextOrder,
|
||||
locationName: place.address || place.name,
|
||||
lat: place.lat || null,
|
||||
lng: place.lng || null,
|
||||
sortOrder: nextOrder,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,26 +477,20 @@ export function onPlaceCreated(tripId: number, placeId: number) {
|
||||
.get(link.journey_id, entryDate) as { m: number | null };
|
||||
const nextOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
link.journey_id,
|
||||
insertSkeletonEntry({
|
||||
journeyId: link.journey_id,
|
||||
tripId,
|
||||
placeId,
|
||||
journey.user_id,
|
||||
place.name,
|
||||
authorId: journey.user_id,
|
||||
title: place.name,
|
||||
entryDate,
|
||||
place.assignment_time || place.place_time || null,
|
||||
place.address || place.name,
|
||||
place.lat || null,
|
||||
place.lng || null,
|
||||
nextOrder,
|
||||
entryTime: place.assignment_time || place.place_time || null,
|
||||
locationName: place.address || place.name,
|
||||
lat: place.lat || null,
|
||||
lng: place.lng || null,
|
||||
sortOrder: nextOrder,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +565,189 @@ export function onPlaceDeleted(placeId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared skeleton INSERT, reused by syncTripPlaces / onPlaceCreated / reconcileTripSkeletons.
|
||||
function insertSkeletonEntry(p: {
|
||||
journeyId: number;
|
||||
tripId: number;
|
||||
placeId: number;
|
||||
authorId: number;
|
||||
title: string;
|
||||
entryDate: string;
|
||||
entryTime: string | null;
|
||||
locationName: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
sortOrder: number;
|
||||
now: number;
|
||||
}) {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
p.journeyId,
|
||||
p.tripId,
|
||||
p.placeId,
|
||||
p.authorId,
|
||||
p.title,
|
||||
p.entryDate,
|
||||
p.entryTime,
|
||||
p.locationName,
|
||||
p.lat,
|
||||
p.lng,
|
||||
p.sortOrder,
|
||||
p.now,
|
||||
p.now,
|
||||
);
|
||||
}
|
||||
|
||||
// Make every journey linked to `tripId` mirror the trip's currently day-assigned
|
||||
// places: add skeletons for newly-assigned places, refresh skeleton snapshots when a
|
||||
// place is moved to another day / its time changes, and drop skeletons for places no
|
||||
// longer assigned. Filled entries are never destroyed — only detached + annotated,
|
||||
// mirroring onPlaceDeleted. Idempotent: a second call with no underlying change is a
|
||||
// no-op (no writes, no broadcast). Called from every assignment mutation path.
|
||||
export function reconcileTripSkeletons(tripId: number, sid?: string | number) {
|
||||
const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
|
||||
journey_id: number;
|
||||
}[];
|
||||
if (!links.length) return;
|
||||
|
||||
const places = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number, da.order_index
|
||||
FROM places p
|
||||
INNER JOIN day_assignments da ON da.place_id = p.id
|
||||
INNER JOIN days d ON da.day_id = d.id
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY d.day_number ASC, da.order_index ASC
|
||||
`,
|
||||
)
|
||||
.all(tripId) as any[];
|
||||
|
||||
// One skeleton per place (a place on multiple days keeps its first-by-day/order row),
|
||||
// matching the one-skeleton-per-place model used by onPlaceCreated.
|
||||
const placeById = new Map<number, any>();
|
||||
for (const place of places) if (!placeById.has(place.id)) placeById.set(place.id, place);
|
||||
const assignedPlaceIds = new Set(placeById.keys());
|
||||
|
||||
const now = ts();
|
||||
for (const { journey_id } of links) {
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journey_id) as
|
||||
| { user_id: number }
|
||||
| undefined;
|
||||
if (!journey) continue;
|
||||
|
||||
let changed = false;
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT id, source_place_id, type, story, title, entry_date, entry_time, location_name, location_lat, location_lng
|
||||
FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?`,
|
||||
)
|
||||
.all(journey_id, tripId) as {
|
||||
id: number;
|
||||
source_place_id: number | null;
|
||||
type: string;
|
||||
story: string | null;
|
||||
title: string | null;
|
||||
entry_date: string | null;
|
||||
entry_time: string | null;
|
||||
location_name: string | null;
|
||||
location_lat: number | null;
|
||||
location_lng: number | null;
|
||||
}[];
|
||||
const existingByPlace = new Map<number, (typeof existing)[number]>();
|
||||
for (const e of existing) if (e.source_place_id != null) existingByPlace.set(e.source_place_id, e);
|
||||
|
||||
// Next sort_order per date for freshly inserted skeletons.
|
||||
const dateMaxOrder = new Map<string, number>();
|
||||
const maxRows = db
|
||||
.prepare(
|
||||
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date',
|
||||
)
|
||||
.all(journey_id) as { entry_date: string; m: number }[];
|
||||
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
|
||||
|
||||
// 1) Upsert a skeleton for every currently-assigned place.
|
||||
for (const place of placeById.values()) {
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
const entryTime = place.assignment_time || place.place_time || null;
|
||||
const locationName = place.address || place.name;
|
||||
const lat = place.lat || null;
|
||||
const lng = place.lng || null;
|
||||
const found = existingByPlace.get(place.id);
|
||||
|
||||
if (!found) {
|
||||
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||
dateMaxOrder.set(entryDate, nextOrder);
|
||||
insertSkeletonEntry({
|
||||
journeyId: journey_id,
|
||||
tripId,
|
||||
placeId: place.id,
|
||||
authorId: journey.user_id,
|
||||
title: place.name,
|
||||
entryDate,
|
||||
entryTime,
|
||||
locationName,
|
||||
lat,
|
||||
lng,
|
||||
sortOrder: nextOrder,
|
||||
now,
|
||||
});
|
||||
changed = true;
|
||||
} else if (found.type === 'skeleton') {
|
||||
// Skeletons follow the place's day/time/location snapshot.
|
||||
const stale =
|
||||
found.title !== place.name ||
|
||||
found.entry_date !== entryDate ||
|
||||
found.entry_time !== entryTime ||
|
||||
found.location_name !== locationName ||
|
||||
found.location_lat !== lat ||
|
||||
found.location_lng !== lng;
|
||||
if (stale) {
|
||||
db.prepare(
|
||||
`UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(place.name, entryDate, entryTime, locationName, lat, lng, now, found.id);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
// Filled entries keep the user's date/story; only location follows the place.
|
||||
const stale =
|
||||
found.location_name !== locationName || found.location_lat !== lat || found.location_lng !== lng;
|
||||
if (stale) {
|
||||
db.prepare(
|
||||
`UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ? WHERE id = ?`,
|
||||
).run(locationName, lat, lng, now, found.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Drop skeletons whose place is no longer assigned to a day in this trip.
|
||||
for (const e of existing) {
|
||||
if (e.source_place_id == null || assignedPlaceIds.has(e.source_place_id)) continue;
|
||||
if (e.type === 'skeleton') {
|
||||
const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(e.id);
|
||||
if (!hasPhotos && !e.story) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(e.id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
|
||||
const newStory = (e.story || '') + note;
|
||||
db.prepare(
|
||||
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?',
|
||||
).run(e.type === 'skeleton' ? 'entry' : e.type, newStory, now, e.id);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function listEntries(journeyId: number, userId: number) {
|
||||
|
||||
@@ -63,8 +63,13 @@ export function deleteJourneyShareLink(journeyId: number, userId: number): boole
|
||||
}
|
||||
|
||||
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
const row = db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
// Photos only ever surface (inline or in the gallery) when share_gallery is on,
|
||||
// so the byte proxy must honour the flag server-side too — the JSON payload
|
||||
// already strips photos when it is off. Enumerable photo ids otherwise stay
|
||||
// fetchable after the owner disables the gallery.
|
||||
if (!row.share_gallery) return null;
|
||||
const photo = db.prepare(`
|
||||
SELECT gp.photo_id, tkp.owner_id, gp.journey_id
|
||||
FROM journey_photos gp
|
||||
@@ -77,8 +82,11 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
}
|
||||
|
||||
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
const row = db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
// Same as the unified photo proxy: no asset bytes leave the host unless the
|
||||
// owner shared the gallery.
|
||||
if (!row.share_gallery) return null;
|
||||
const photo = db.prepare(`
|
||||
SELECT tkp.owner_id FROM journey_photos gp
|
||||
JOIN trek_photos tkp ON tkp.id = gp.photo_id
|
||||
|
||||
@@ -21,6 +21,22 @@ export function isValidAssetId(id: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden assets have no generated thumbnail — Immich's own enum documents
|
||||
* `AssetVisibility.Hidden` as "Video part of the LivePhotos and MotionPhotos",
|
||||
* and its UI never shows them standalone. Surfacing or persisting one yields a
|
||||
* permanently broken tile, since nothing re-checks visibility on the render
|
||||
* path (#1474).
|
||||
*
|
||||
* Load-bearing on Immich < 1.133, which predates the `visibility` field: those
|
||||
* servers strip the `visibility: 'timeline'` search filter and never defaulted
|
||||
* `isVisible`, so they return hidden assets and this is the only thing stopping
|
||||
* them. They mark the same state as `isVisible: false`.
|
||||
*/
|
||||
function isVisibleAsset(a: any): boolean {
|
||||
return a.visibility !== 'hidden' && a.isVisible !== false;
|
||||
}
|
||||
|
||||
// ── Connection Settings ────────────────────────────────────────────────────
|
||||
|
||||
export function getConnectionSettings(userId: number) {
|
||||
@@ -170,6 +186,18 @@ export async function searchPhotos(
|
||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||
// No type filter — surface videos alongside images (#823).
|
||||
// Immich 1.133–1.144 defaulted metadata search to `timeline` visibility;
|
||||
// v3 defaults to any visibility except `locked`, which is what started
|
||||
// surfacing Live Photo motion parts as broken tiles (#1474). Ask for
|
||||
// `timeline` explicitly so hidden assets never cross the wire and a full
|
||||
// page stays a full page of renderable tiles.
|
||||
//
|
||||
// `visibility` only exists from 1.133.0. Older servers strip it — Immich
|
||||
// validates with `whitelist: true` and no `forbidNonWhitelisted`, so an
|
||||
// unknown property is dropped, never a 400 — and they never defaulted
|
||||
// `isVisible` either, so they still return hidden assets. isVisibleAsset()
|
||||
// below is the only guard on those versions. Do not remove it.
|
||||
visibility: 'timeline',
|
||||
size,
|
||||
page,
|
||||
}),
|
||||
@@ -178,13 +206,19 @@ export async function searchPhotos(
|
||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
const assets = items.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
|
||||
}));
|
||||
// Belt-and-braces: `visibility: 'timeline'` above should mean Immich never
|
||||
// sends a hidden asset, but an older server that ignores the filter would
|
||||
// otherwise render broken tiles. hasMore stays on the raw page length so
|
||||
// pagination still advances past a filtered page.
|
||||
const assets = items
|
||||
.filter(isVisibleAsset)
|
||||
.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
|
||||
}));
|
||||
return { assets, hasMore: items.length >= size };
|
||||
} catch {
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
@@ -343,6 +377,88 @@ export async function listAlbums(
|
||||
}
|
||||
}
|
||||
|
||||
/** Immich caps `size` at 1000 for metadata search; a page cap bounds pathological albums. */
|
||||
const ALBUM_PAGE_SIZE = 1000;
|
||||
const ALBUM_MAX_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Fetch every asset in an album, across Immich v2 and v3.
|
||||
*
|
||||
* Immich v3 removed `assets` from `AlbumResponseDto`, so `GET /api/albums/:id`
|
||||
* no longer carries album contents (#1492) and we fall back to an
|
||||
* `albumIds`-filtered metadata search.
|
||||
*
|
||||
* We feature-detect on the presence of `assets` rather than switching on a
|
||||
* probed server version. The two paths are NOT interchangeable:
|
||||
*
|
||||
* - Before v3, `searchMetadata` unconditionally scopes results to
|
||||
* `[self, ...partners]` (`asset.ownerId = ANY(userIds)`), so an albumIds
|
||||
* search against an album shared by a non-partner returns nothing. v3 added
|
||||
* an albumIds branch that checks `AlbumRead` and skips that owner filter.
|
||||
* - 1.133–1.144 also default `visibility` to `timeline`, dropping archived
|
||||
* album assets.
|
||||
* - `albumIds` only exists from 1.135.0, and Immich strips unknown properties
|
||||
* rather than rejecting them (`whitelist: true`, no `forbidNonWhitelisted`).
|
||||
* Searching an older server would therefore silently drop the album filter
|
||||
* and return the user's ENTIRE library as the album's contents.
|
||||
*
|
||||
* Feature detection makes all three moot: `assets` is present on every version
|
||||
* that has any of those problems (through 1.144.1) and absent only on v3, where
|
||||
* the search path is correct. A version probe with a wrong boundary would not be.
|
||||
*/
|
||||
async function fetchAlbumAssets(
|
||||
creds: { immich_url: string; immich_api_key: string },
|
||||
albumId: string,
|
||||
): Promise<{ assets?: any[]; status?: number }> {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { status: resp.status };
|
||||
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
if (Array.isArray(albumData.assets)) return { assets: albumData.assets };
|
||||
|
||||
return fetchAlbumAssetsViaSearch(creds, albumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immich v3 album contents, via `POST /api/search/metadata` filtered by `albumIds`.
|
||||
*
|
||||
* `withExif` is required: it has no default and gates an inner join
|
||||
* (`searchAssetBuilder`: `.$if(!!options.withExif, withExifInner)`), so without
|
||||
* it Immich omits `exifInfo` entirely and every photo's city/country goes null.
|
||||
*/
|
||||
async function fetchAlbumAssetsViaSearch(
|
||||
creds: { immich_url: string; immich_api_key: string },
|
||||
albumId: string,
|
||||
): Promise<{ assets?: any[]; status?: number }> {
|
||||
const all: any[] = [];
|
||||
|
||||
for (let page = 1; page <= ALBUM_MAX_PAGES; page++) {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
albumIds: [albumId],
|
||||
withExif: true,
|
||||
withDeleted: false,
|
||||
size: ALBUM_PAGE_SIZE,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { status: resp.status };
|
||||
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
all.push(...items);
|
||||
if (items.length < ALBUM_PAGE_SIZE) break;
|
||||
}
|
||||
|
||||
return { assets: all };
|
||||
}
|
||||
|
||||
export async function getAlbumPhotos(
|
||||
userId: number,
|
||||
albumId: string,
|
||||
@@ -351,19 +467,19 @@ export async function getAlbumPhotos(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
|
||||
}));
|
||||
const result = await fetchAlbumAssets(creds, albumId);
|
||||
if (!result.assets) return { error: 'Failed to fetch album', status: result.status };
|
||||
// Albums legitimately contain hidden assets on both Immich versions (the v2
|
||||
// album body and the v3 album search both return them), so filter here (#1474).
|
||||
const assets = result.assets
|
||||
.filter(isVisibleAsset)
|
||||
.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
|
||||
}));
|
||||
return { assets };
|
||||
} catch {
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
@@ -383,13 +499,11 @@ export async function syncAlbumAssets(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums/${response.data}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||
const albumResult = await fetchAlbumAssets(creds, response.data as string);
|
||||
if (!albumResult.assets) return { error: 'Failed to fetch album', status: albumResult.status };
|
||||
// Hidden assets must never be persisted: the render path never re-checks
|
||||
// visibility, so a stored hidden asset is a permanently broken tile (#1474).
|
||||
const assets = albumResult.assets.filter((a: any) => a.type === 'IMAGE' && isVisibleAsset(a));
|
||||
|
||||
const selection: Selection = {
|
||||
provider: 'immich',
|
||||
|
||||
@@ -17,7 +17,8 @@ export type NotifEventType =
|
||||
| 'collab_message'
|
||||
| 'packing_tagged'
|
||||
| 'version_available'
|
||||
| 'synology_session_cleared';
|
||||
| 'synology_session_cleared'
|
||||
| 'plugin_notification';
|
||||
|
||||
export interface AvailableChannels {
|
||||
email: boolean;
|
||||
@@ -40,6 +41,7 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||
packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
version_available: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
synology_session_cleared: ['inapp'],
|
||||
plugin_notification: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||
};
|
||||
|
||||
/** Events that target admins only (shown in admin panel, not in user settings). */
|
||||
|
||||
@@ -60,6 +60,16 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: () => '/dashboard',
|
||||
},
|
||||
// ── Plugin-mediated notification (#plugins) — raw title/body carried as params,
|
||||
// rendered by the passthrough keys. The plugin never picks a recipient directly;
|
||||
// the host forces scope/target to the acting user or a trip they belong to. ──────
|
||||
plugin_notification: {
|
||||
inAppType: 'navigate',
|
||||
titleKey: 'notif.plugin.title',
|
||||
textKey: 'notif.plugin.text',
|
||||
navigateTextKey: 'notif.action.view',
|
||||
navigateTarget: p => p.link || null,
|
||||
},
|
||||
// ── Production events ─────────────────────────────────────────────────────
|
||||
trip_invite: {
|
||||
inAppType: 'navigate',
|
||||
|
||||
@@ -474,13 +474,14 @@ export function refreshTokens(
|
||||
|
||||
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
|
||||
|
||||
// Revoke old pair immediately (rotation) and issue new pair linked to old row
|
||||
// Revoke old pair immediately (rotation) and issue new pair linked to old row.
|
||||
// Do NOT revoke active MCP sessions here: a legitimate refresh isn't a security
|
||||
// event (that's handled above, in the replay-detection branch), and mcpHandler
|
||||
// already re-validates session.userId/clientId against the new token on every
|
||||
// request. Killing the session on every routine hourly refresh broke long-lived
|
||||
// MCP connections (#1475).
|
||||
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
|
||||
|
||||
// Terminate active MCP sessions for the old token's client so client must re-authenticate
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
|
||||
@@ -307,12 +307,20 @@ export function listBags(tripId: string | number) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Owner + collaborators of a trip — the only user ids that may be assigned to a bag. */
|
||||
function tripRosterIds(tripId: string | number): Set<number> {
|
||||
const rows = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ? UNION SELECT user_id FROM trips WHERE id = ?').all(tripId, tripId) as { user_id: number }[];
|
||||
return new Set(rows.map(r => r.user_id));
|
||||
}
|
||||
|
||||
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
||||
if (!bag) return null;
|
||||
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of userIds) ins.run(bagId, uid);
|
||||
// Only real trip members may be bag members — never write an arbitrary account id.
|
||||
const roster = tripRosterIds(tripId);
|
||||
for (const uid of userIds) if (roster.has(uid)) ins.run(bagId, uid);
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||
@@ -338,6 +346,8 @@ export function updateBag(
|
||||
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
||||
if (!bag) return null;
|
||||
|
||||
// A bag may only be assigned to a real trip member; an off-roster id becomes unassigned.
|
||||
const assignUser = data.user_id != null && tripRosterIds(tripId).has(data.user_id) ? data.user_id : null;
|
||||
db.prepare(`UPDATE packing_bags SET
|
||||
name = COALESCE(?, name),
|
||||
color = COALESCE(?, color),
|
||||
@@ -348,7 +358,7 @@ export function updateBag(
|
||||
data.color || null,
|
||||
data.weight_limit_grams ?? (bag as any).weight_limit_grams ?? null,
|
||||
bodyKeys?.includes('user_id') ? 1 : 0,
|
||||
data.user_id ?? null,
|
||||
assignUser,
|
||||
bagId
|
||||
);
|
||||
return db.prepare('SELECT b.*, COALESCE(u.display_name, u.username) as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?').get(bagId);
|
||||
|
||||
@@ -198,8 +198,9 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Packing
|
||||
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
|
||||
// Packing — a public viewer is neither owner nor recipient, so only Common items
|
||||
// may surface; never a co-member's private/personal packing items (#858).
|
||||
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC').all(tripId);
|
||||
|
||||
// Budget
|
||||
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
|
||||
@@ -235,8 +236,16 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Honour every share flag server-side — the client gates these too, but it must
|
||||
// not rely on that (mirrors journeyShareService). share_map covers the whole
|
||||
// itinerary: days, their assignments/notes, and the place list with coordinates,
|
||||
// addresses and notes. Withhold it when the owner disabled the map.
|
||||
return {
|
||||
trip, baseCurrency, days, assignments, dayNotes, places, categories, permissions,
|
||||
trip, baseCurrency, categories, permissions,
|
||||
days: permissions.share_map ? days : [],
|
||||
assignments: permissions.share_map ? assignments : {},
|
||||
dayNotes: permissions.share_map ? dayNotes : {},
|
||||
places: permissions.share_map ? places : [],
|
||||
reservations: permissions.share_bookings ? reservations : [],
|
||||
accommodations: permissions.share_bookings ? accommodations : [],
|
||||
packing: permissions.share_packing ? packing : [],
|
||||
@@ -255,9 +264,13 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
*/
|
||||
export function getSharedPlacePhotoPath(token: string, placeId: string): string | null {
|
||||
const shareRow = db.prepare(
|
||||
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as { trip_id: string } | undefined;
|
||||
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as { trip_id: string; share_map: number } | undefined;
|
||||
if (!shareRow) return null;
|
||||
// Place photos belong to the map/itinerary section — withhold them when the
|
||||
// owner disabled the map, matching getSharedTripData which no longer returns
|
||||
// the places (and thus their ids) in that case.
|
||||
if (!shareRow.share_map) return null;
|
||||
|
||||
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
|
||||
const place = db.prepare(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import path from 'path';
|
||||
import tzlookup from 'tz-lookup';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import fs from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db, isOwner } from '../db/database';
|
||||
import { erasePluginUserData } from './userCleanupService';
|
||||
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
||||
import { Trip, User } from '../types';
|
||||
import { listDays, listAccommodations } from './dayService';
|
||||
import { listDays, listAccommodations, addDays } from './dayService';
|
||||
import { listBudgetItems } from './budgetService';
|
||||
import { listItems as listPackingItems } from './packingService';
|
||||
import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService';
|
||||
@@ -518,9 +521,14 @@ export function renameGuest(tripId: string | number, guestUserId: number, name:
|
||||
|
||||
export function deleteGuest(tripId: string | number, guestUserId: number): boolean {
|
||||
if (!guestOfTrip(tripId, guestUserId)) return false;
|
||||
// A guest is still a user id a plugin may hold data for, so erase that too — the
|
||||
// host-side per-user tables + a durable own-db erasure per granted plugin — exactly
|
||||
// like a full account deletion (otherwise a deleted guest's plugin data lingers).
|
||||
erasePluginUserData(guestUserId);
|
||||
// Deleting the guest's users row cascades its membership and every assignment join
|
||||
// (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys.
|
||||
db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId);
|
||||
emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -551,11 +559,98 @@ function foldICS(ics: string): string {
|
||||
return ics.split('\r\n').map(foldLine).join('\r\n');
|
||||
}
|
||||
|
||||
// ── ICS time-zone helpers ────────────────────────────────────────────────────
|
||||
// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an
|
||||
// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453).
|
||||
|
||||
// Resolve an IANA zone (e.g. "Europe/Paris") from coordinates. Returns null for
|
||||
// missing/invalid coords instead of throwing — tz-lookup throws RangeError when
|
||||
// lat/lng are out of range.
|
||||
function resolveZone(lat: unknown, lng: unknown): string | null {
|
||||
if (
|
||||
typeof lat !== 'number' ||
|
||||
typeof lng !== 'number' ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lng)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return tzlookup(lat, lng);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a
|
||||
// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a
|
||||
// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString —
|
||||
// would crash the whole ICS export (and drop the trip from the all-trips feed).
|
||||
// Validate once so an invalid zone degrades to a floating local time instead.
|
||||
const _tzValidCache = new Map<string, boolean>();
|
||||
function isValidTimeZone(zone: string): boolean {
|
||||
const cached = _tzValidCache.get(zone);
|
||||
if (cached !== undefined) return cached;
|
||||
let ok = false;
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: zone });
|
||||
ok = true;
|
||||
} catch {
|
||||
// Unknown/invalid zone → ok stays false.
|
||||
}
|
||||
// Bound the cache — the key is a free-form (plugin/importer-written) zone string,
|
||||
// so cap distinct entries rather than growing for the process lifetime.
|
||||
if (_tzValidCache.size >= 1000) _tzValidCache.clear();
|
||||
_tzValidCache.set(zone, ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// UTC offset ("+0200") the zone uses on the given YYYYMMDD date. Only feeds the
|
||||
// fallback VTIMEZONE offset; iOS/Google resolve the named zone from their own
|
||||
// IANA database, so a single representative offset is sufficient.
|
||||
function tzOffsetString(zone: string, yyyymmdd: string): string {
|
||||
const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}T12:00:00Z`;
|
||||
const probe = new Date(iso);
|
||||
if (Number.isNaN(probe.getTime())) return '+0000';
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
timeZoneName: 'longOffset',
|
||||
}).formatToParts(probe);
|
||||
const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT';
|
||||
const m = raw.match(/GMT([+-])(\d{2}):?(\d{2})?/);
|
||||
if (!m) return '+0000'; // "GMT" (UTC) has no offset digits
|
||||
return `${m[1]}${m[2]}${m[3] ?? '00'}`;
|
||||
}
|
||||
|
||||
// Minimal but RFC-valid VTIMEZONE. Smart clients override it with their own tz
|
||||
// rules; dumb clients fall back to this fixed offset.
|
||||
function buildVTimezone(zone: string, yyyymmdd: string): string {
|
||||
const off = tzOffsetString(zone, yyyymmdd);
|
||||
return (
|
||||
'BEGIN:VTIMEZONE\r\n' +
|
||||
`TZID:${zone}\r\n` +
|
||||
'BEGIN:STANDARD\r\n' +
|
||||
'DTSTART:19700101T000000\r\n' +
|
||||
`TZOFFSETFROM:${off}\r\n` +
|
||||
`TZOFFSETTO:${off}\r\n` +
|
||||
`TZNAME:${zone}\r\n` +
|
||||
'END:STANDARD\r\n' +
|
||||
'END:VTIMEZONE\r\n'
|
||||
);
|
||||
}
|
||||
|
||||
export function exportICS(tripId: string | number): { ics: string; filename: string } {
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
|
||||
if (!trip) throw new NotFoundError('Trip not found');
|
||||
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(tripId) as any[];
|
||||
const reservations = db
|
||||
.prepare(
|
||||
`SELECT r.*, pl.lat AS place_lat, pl.lng AS place_lng
|
||||
FROM reservations r
|
||||
LEFT JOIN places pl ON r.place_id = pl.id
|
||||
WHERE r.trip_id = ?`,
|
||||
)
|
||||
.all(tripId) as any[];
|
||||
|
||||
const esc = (s: string) => s
|
||||
.replace(/\\/g, '\\\\')
|
||||
@@ -583,14 +678,35 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
return d.replace(/[-:]/g, '');
|
||||
};
|
||||
|
||||
// Zones referenced by timed events → representative YYYYMMDD (for the fallback
|
||||
// VTIMEZONE offset). Populated by dtLine; emitted once as VTIMEZONE blocks.
|
||||
const usedZones = new Map<string, string>();
|
||||
|
||||
// Emit a DTSTART/DTEND line, attaching TZID when the event's zone is known so
|
||||
// subscribers see the time in TREK's zone. Falls back to a floating local time
|
||||
// (unchanged behavior) when no zone resolves or the value is not a date-time.
|
||||
const dtLine = (
|
||||
prop: 'DTSTART' | 'DTEND',
|
||||
wallClock: string,
|
||||
zone: string | null,
|
||||
refDate?: string,
|
||||
): string => {
|
||||
const val = fmtDateTime(wallClock, refDate);
|
||||
if (zone && isValidTimeZone(zone) && /^\d{8}T\d{6}$/.test(val)) {
|
||||
if (!usedZones.has(zone)) usedZones.set(zone, val.slice(0, 8));
|
||||
return `${prop};TZID=${zone}:${val}\r\n`;
|
||||
}
|
||||
return `${prop}:${val}\r\n`;
|
||||
};
|
||||
|
||||
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
|
||||
|
||||
// Trip as all-day event
|
||||
// Trip as all-day event. DTEND is exclusive, so it must be the day *after* the last
|
||||
// day. addDays() stays in UTC — building a local-time Date here dropped the trip's
|
||||
// last day on any server east of Greenwich (#1453).
|
||||
if (trip.start_date && trip.end_date) {
|
||||
const endNext = new Date(trip.end_date + 'T00:00:00');
|
||||
endNext.setDate(endNext.getDate() + 1);
|
||||
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
||||
const endStr = fmtDate(addDays(trip.end_date, 1));
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
|
||||
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
@@ -603,6 +719,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, p.address as place_address,
|
||||
p.lat as place_lat, p.lng as place_lng,
|
||||
COALESCE(da.assignment_time, p.place_time) as effective_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as effective_end_time
|
||||
FROM day_assignments da
|
||||
@@ -620,10 +737,11 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
|
||||
// Timed assignments → individual events
|
||||
for (const a of timed) {
|
||||
const zone = resolveZone(a.place_lat, a.place_lng);
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`;
|
||||
ics += `DTSTART:${fmtDateTime(a.effective_time, day.date + 'T00:00')}\r\n`;
|
||||
ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00');
|
||||
if (a.effective_end_time) {
|
||||
ics += `DTEND:${fmtDateTime(a.effective_end_time, day.date + 'T00:00')}\r\n`;
|
||||
ics += dtLine('DTEND', a.effective_end_time, zone, day.date + 'T00:00');
|
||||
}
|
||||
ics += `SUMMARY:${esc(a.place_name)}\r\n`;
|
||||
let desc = '';
|
||||
@@ -637,9 +755,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
// Build all-day summary event if there are untimed activities or notes
|
||||
if (untimed.length > 0 || notes.length > 0) {
|
||||
const dayTitle = day.title || `Day ${day.day_number}`;
|
||||
const endNext = new Date(day.date + 'T00:00:00');
|
||||
endNext.setDate(endNext.getDate() + 1);
|
||||
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
||||
const endStr = fmtDate(addDays(day.date, 1));
|
||||
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`;
|
||||
ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`;
|
||||
@@ -680,10 +796,12 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time;
|
||||
if (!isDate(datePart)) return null; // time-only (relative "Day N" trips)
|
||||
if (r.reservation_time.includes('T')) {
|
||||
let out = `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
|
||||
// Hotels/restaurants: derive the zone from the linked place, if any.
|
||||
const zone = resolveZone(r.place_lat, r.place_lng);
|
||||
let out = dtLine('DTSTART', r.reservation_time, zone);
|
||||
if (r.reservation_end_time) {
|
||||
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
|
||||
if (endDt.length >= 15) out += `DTEND:${endDt}\r\n`;
|
||||
if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, zone, r.reservation_time);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -697,9 +815,13 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
const last = ordered[ordered.length - 1];
|
||||
if (!isDate(first.local_date)) return null;
|
||||
if (isTime(first.local_time)) {
|
||||
let out = `DTSTART:${fmtDateTime(`${first.local_date}T${first.local_time}`)}\r\n`;
|
||||
// Transport: departure endpoint zone drives DTSTART, arrival drives DTEND.
|
||||
// Prefer the stored IANA zone; fall back to the endpoint's coordinates.
|
||||
const startZone = first.timezone || resolveZone(first.lat, first.lng);
|
||||
let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone);
|
||||
if (last !== first && isDate(last.local_date) && isTime(last.local_time)) {
|
||||
out += `DTEND:${fmtDateTime(`${last.local_date}T${last.local_time}`)}\r\n`;
|
||||
const endZone = last.timezone || resolveZone(last.lat, last.lng);
|
||||
out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -744,6 +866,14 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
|
||||
ics += 'END:VCALENDAR\r\n';
|
||||
|
||||
// Define every referenced zone with a VTIMEZONE, inserted before the first
|
||||
// event so TZID references resolve. No-op when no timed event carried a zone.
|
||||
if (usedZones.size > 0) {
|
||||
let vtz = '';
|
||||
for (const [zone, yyyymmdd] of usedZones) vtz += buildVTimezone(zone, yyyymmdd);
|
||||
ics = ics.replace('BEGIN:VEVENT', vtz + 'BEGIN:VEVENT');
|
||||
}
|
||||
|
||||
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_');
|
||||
return { ics: foldICS(ics), filename: `${safeFilename}.ics` };
|
||||
}
|
||||
@@ -923,7 +1053,7 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
|
||||
|
||||
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
|
||||
|
||||
export function getTripSummary(tripId: number) {
|
||||
export function getTripSummary(tripId: number, viewerUserId?: number) {
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined;
|
||||
if (!trip) return null;
|
||||
|
||||
@@ -944,7 +1074,9 @@ export function getTripSummary(tripId: number) {
|
||||
currency: trip.currency,
|
||||
};
|
||||
|
||||
const packingItems = listPackingItems(tripId);
|
||||
// Thread the viewer so another member's private/personal packing items (#858)
|
||||
// stay hidden — without it listItems returns the UNFILTERED list.
|
||||
const packingItems = listPackingItems(tripId, viewerUserId);
|
||||
const packing = {
|
||||
items: packingItems,
|
||||
total: packingItems.length,
|
||||
|
||||
@@ -1,4 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import { db } from '../db/database';
|
||||
import { pluginsDataRoot } from '../nest/plugins/paths';
|
||||
|
||||
/**
|
||||
* Erase a user's PLUGIN-held data on account deletion. Two parts:
|
||||
* 1. Host-side per-user plugin tables (encrypted config values, OAuth access/refresh
|
||||
* tokens, in-flight OAuth state) — deleted directly; these live in trek.db, not in
|
||||
* a plugin's own db, so nothing else ever removes them.
|
||||
* 2. A durable erasure row per plugin that holds `hook:user-data`, so its OWN db is
|
||||
* purged of the user (drained to the plugin when it is next active).
|
||||
*
|
||||
* Runs in the core deletion path (NOT via the plugin runtime), so it works even when
|
||||
* TREK_PLUGINS_ENABLED=false or before the runtime has booted — otherwise a deletion in
|
||||
* those windows would leave the user's plugin data behind forever. Best-effort per table
|
||||
* so a slimmed-down schema (some tests) can't fail the user deletion itself.
|
||||
*/
|
||||
export function erasePluginUserData(userId: number): void {
|
||||
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
|
||||
try { db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(userId); } catch { /* table absent (slim schema) */ }
|
||||
}
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const installed = new Set(rows.map((r) => r.id));
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
|
||||
for (const r of rows) {
|
||||
let perms: unknown;
|
||||
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
|
||||
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
|
||||
}
|
||||
// Also enqueue for plugins UNINSTALLED with their data retained (deleteData=false):
|
||||
// their data dir still holds the user's rows and a same-id reinstall would re-adopt
|
||||
// them. No permissions record survives uninstall, so we can't check hook:user-data —
|
||||
// enqueue for every orphan data dir; the row sits inert (no FK) and drains only if
|
||||
// that id is reinstalled + active (erasure delivery is a duty, not grant-gated).
|
||||
try {
|
||||
for (const entry of fs.readdirSync(pluginsDataRoot(), { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !installed.has(entry.name)) insert.run(entry.name, userId);
|
||||
}
|
||||
} catch { /* no plugin data root yet */ }
|
||||
} catch { /* plugins / queue table absent (slim schema) */ }
|
||||
}
|
||||
|
||||
function cleanupUserReferences(userId: number): void {
|
||||
db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
|
||||
@@ -15,6 +56,7 @@ function cleanupUserReferences(userId: number): void {
|
||||
export function deleteUserCompletely(userId: number): void {
|
||||
const tx = db.transaction((id: number) => {
|
||||
cleanupUserReferences(id);
|
||||
erasePluginUserData(id);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
});
|
||||
tx(userId);
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// `tz-lookup` ships no type definitions. It exports a single function that maps a
|
||||
// latitude/longitude to an IANA time-zone name (e.g. `Europe/Paris`) and throws a
|
||||
// RangeError when the coordinates are out of range.
|
||||
|
||||
declare module 'tz-lookup' {
|
||||
const tzlookup: (lat: number, lng: number) => string;
|
||||
export default tzlookup;
|
||||
}
|
||||
@@ -101,6 +101,55 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
return { allowed: true, isPrivate: false, resolvedIp };
|
||||
}
|
||||
|
||||
/** Link-local / cloud-metadata addresses — never a legitimate model host. */
|
||||
function isLinkLocal(ip: string): boolean {
|
||||
const addr = (ip.startsWith('[') ? ip.slice(1, -1) : ip).toLowerCase();
|
||||
// IPv4 link-local — AWS, GCP, Azure and OpenStack all serve credentials from 169.254.169.254.
|
||||
if (addr.startsWith('169.254.')) return true;
|
||||
// IPv4-mapped (::ffff:169.254.x) and IPv4-compatible (::a9fe:xxxx = ::169.254.x) spellings.
|
||||
if (/^::ffff:169\.254\./.test(addr) || /^::(ffff:)?a9fe:/.test(addr)) return true;
|
||||
// IPv6 link-local fe80::/10 — the whole range (fe80: … febf:), not just the fe80: prefix.
|
||||
if (/^fe[89ab][0-9a-f]:/.test(addr)) return true;
|
||||
// AWS IMDSv6 sits in a fixed ULA slot; block it specifically while leaving the rest of
|
||||
// fc00::/7 reachable (a self-hosted model server may legitimately sit on a ULA address).
|
||||
if (addr === 'fd00:ec2::254' || addr.startsWith('fd00:ec2:')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch for a user-configurable LLM endpoint. Unlike safeFetch() this
|
||||
* deliberately ALLOWS loopback and private/LAN targets — a local Ollama on
|
||||
* localhost or a model server on the LAN is the normal, supported config, so
|
||||
* blocking them would break a legitimate setup. It blocks only the link-local /
|
||||
* cloud-metadata range (169.254.0.0/16, fe80::/10), which is never a real LLM
|
||||
* host but is the credential-theft SSRF target. The connection is pinned to the
|
||||
* resolved IP, so a hostname cannot rebind to the metadata address between the
|
||||
* check and the request.
|
||||
*/
|
||||
export async function safeFetchLlm(url: string, init?: RequestInit): Promise<Response> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new SsrfBlockedError('Invalid URL');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new SsrfBlockedError('Only HTTP and HTTPS URLs are allowed');
|
||||
}
|
||||
let resolvedIp: string;
|
||||
try {
|
||||
resolvedIp = (await dns.lookup(parsed.hostname)).address;
|
||||
} catch (error_) {
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
throw new SsrfBlockedError(`Could not resolve hostname (${code})`);
|
||||
}
|
||||
if (isLinkLocal(resolvedIp)) {
|
||||
throw new SsrfBlockedError('Requests to link-local / cloud-metadata addresses are not allowed');
|
||||
}
|
||||
const dispatcher = createPinnedDispatcher(resolvedIp, true);
|
||||
return fetch(url, { ...init, dispatcher } as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by safeFetch() when the URL is blocked by the SSRF guard.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { db, canAccessTrip } from './db/database';
|
||||
import { consumeEphemeralTokenWithMeta } from './services/ephemeralTokens';
|
||||
import { emitPluginEvent } from './plugin-event-sink';
|
||||
import { emitPluginEvent, pluginEventMeta } from './plugin-event-sink';
|
||||
import { User } from './types';
|
||||
import http from 'node:http';
|
||||
|
||||
@@ -196,7 +196,7 @@ function broadcast(tripId: number | string, eventType: string, payload: Record<s
|
||||
// Announce every CORE trip event (name only, never the payload) to subscribed
|
||||
// plugins — before the room check so it fires even with no connected viewers, and
|
||||
// skipping plugin:* re-broadcasts so a plugin's own events can't loop back.
|
||||
if (!eventType.startsWith('plugin:')) emitPluginEvent(tripId, eventType);
|
||||
if (!eventType.startsWith('plugin:')) emitPluginEvent(tripId, eventType, pluginEventMeta(eventType, payload));
|
||||
const room = rooms.get(tripId);
|
||||
if (!room || room.size === 0) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user