mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
3.2.1 (#1433)
* fix(plugins): prevent arbitrary api access * fix(planner): disable drag & drop on mobile so the places list scrolls (#1432) On touch devices the draggable rows hijack the scroll gesture, so dragging to scroll started an HTML5 drag and popped up the file-import overlay instead of scrolling. Gate the draggable rows and the sidebar file-drop handlers on !isMobile across the places sidebar and the day plan (places, transports, notes), and hide the grip handle — the arrow reorder buttons take over there. * fix(inspector): make the remove-from-day button icon-only on mobile * fix(collections): show the save picker above the mobile place detail * fix(plugins): seal IPC parent/child for good * test(inspector): match the icon-only remove-from-day button * feat(sdk): switch plain ts for clack/prompts interactive session * feat(plugins): force-refresh the registry from the rescan button The registry is cached for 30 min server-side and GitHub serves it with a 5-min CDN cache, so a freshly published plugin could take up to ~35 min to appear. The rescan/reload button now force-pulls the registry: it bypasses the in-memory cache and appends a cache-buster + no-cache headers to beat the CDN, and refreshes the browse grid immediately. * feat(sdk): bump plugin version * fix(stored settings): prevent local storage drop when update not successful * feat(plugins): sideload plugins by uploading a .zip Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for installing a plugin archive directly — handy for testing a build before it goes to the registry. It reuses the registry install pipeline (slip/bomb-safe extract, strict manifest validation, native-binary scan) via a new POST /admin/plugins/upload, and only skips the registry sha256/signature checks that a sideload can't have. Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no GitHub link, no auto-update) and always land INACTIVE — replacing a running or active plugin stops it and clears the active flag first, so new code never runs without a fresh activation + permission consent. * fix(planner): keep the day-plan collapse state after fully closing the page The expanded/collapsed days were stored in sessionStorage, which survives a reload but is wiped when the tab or window is closed — so every fresh open re-expanded all days, which is tedious to re-collapse on long (10+ day) trips. Store it in localStorage instead so a collapsed layout sticks until it's changed. * fix(i18n): correct Vietnamese translation of 'Disabled' (#1438) 'Tàn tật' means physically handicapped/disabled-person, not the off/disabled state of a toggle. Replace with 'Tắt' (off), matching the existing 'admin.plugins.stateOff' translation. Affects admin.notifications.none and admin.addons.disabled. * add the code of conduct * fix(plugins): let widgets follow the in-app dark-mode toggle The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we only sent the context — including the theme — once, on trek:ready. So toggling dark mode in TREK left already-mounted widgets on the old theme until a reload. Watch the <html> `dark` class and re-post the context when it flips; plugins already re-apply the theme on trek:context. * fix(plugins): deliver widget context on load so the theme is right on first paint * fix(plugins): give widget cards the native glassy look and auto-height Widget plugins rendered in a plain card with a fixed 180px body, so they looked foreign next to the glassy dashboard tools and taller widgets had their controls clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase title) and let the body grow to the height the widget reports over trek:resize. * feat(plugins): add read/rwite costs * feat(plugin): better readme/index.js * feat(plugin): better readme/index.js * feat(plugins): hand widgets TREK's theme tokens, formats and display identity Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design tokens for the current theme), `formats` (currency/date/units/timezone) and a `user` display object (name/avatar/isAdmin — never the email, role only as a boolean). Re-sent on every theme toggle. A widget can now apply the tokens and match the host exactly, in both themes and under a custom appearance, instead of hard-coding a palette that drifts — so plugins feel native, not bolted-on. * feat(plugins): hand plugins the full palette and appearance state The theme context only carried a ~19-token subset read off <html> and only followed the dark-mode toggle. Widen it to the whole global (:root/.dark) palette — surfaces, text, borders, the accent family, semantic + soft fills, shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme, custom accent and high-contrast live, not just light/dark. Also send an `appearance` block (scheme, density, reduced-motion, no-transparency) mirrored from the attributes applyAppearance writes on <html>, and re-post the context whenever any of those actually change (a small signature dedupes unrelated mutations) so plugins restyle in step with the app. * feat(plugins): ship a design kit so plugin UIs look native A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so authors had to re-derive the whole look by hand, and most didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons, inputs, chips, rows) that consumes the tokens the host already sends and swaps light/dark, plus a small bootstrap that applies those tokens, mirrors the appearance flags, auto-reports the frame height and exposes a `window.trek` helper over the existing bridge. Both are plain strings meant to be inlined (the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a `<!-- trek:ui -->` marker. No new capability — only a native look. * feat(plugins): deliver the design kit — native scaffold + inline on dev/pack A new page/widget scaffolds a native, glassy starter that talks over window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it serves /ui) and `pack` (as the file enters the archive) expand it into the inlined kit — so the file stays a one-line opt-in and a rebuild always ships the current kit. Existing plugins opt in the same way, by dropping the marker. * feat(plugins): faithful themed host preview in dev `dev` served the plugin UI raw at /ui — top-level, with no host — so the theme, context and bridge never fired and authors couldn't see the design kit render. Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's isolation) and plays the host — posts trek:context with a theme/accent/appearance toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces resize/notify/navigate. /ui stays as the raw doc for debugging. * docs(plugins): document the design kit, window.trek and the token contract Rewrite the client section of the Plugin Development wiki kit-first: the `<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the `/preview` host preview, the full `trek:context` payload (now the whole palette plus an `appearance` block) and how to apply tokens by hand. Add a "Build a native UI" section + the new exports to the SDK README. * feat(budget): add 'Outstanding amount' card * fix(translations): finish translating new keys * feat(plugins): trip-page plugins — a plugin tab inside every trip Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with no dashboard nav entry. This is the most-asked planner-extension request from discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget planner). It reuses PluginFrame and the existing tab system — the frame already receives the current tripId over trek:context — so there is no bridge or security change: only the manifest type enum (server + SDK), the client feed classification (pluginStore.tripPages), and one render branch in the planner. The SDK scaffolds it with `create --type trip-page`. * fix(apple wallet): support for .pkpasses * feat(plugins): permission-gated write APIs for the planner (#1429) Plugins can now WRITE core planner data, not just read it, through curated, membership-checked methods — so downstream features can live in plugins instead of long-lived core patches. Four new scopes: db:write:places (create/update/delete places), db:write:days (days), db:write:itinerary (assign/unassign a place on a day) and db:write:trips (update trip fields). Each ctx method mirrors costs.create: it validates the input against the SAME @trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad has none, so its writes are refused), checks trip access AND the app's edit permission (place_edit / day_edit / trip_edit), delegates to the real services, broadcasts the same events so open sessions update live, and records the write in the tamper-evident capability audit. No new route, no sandbox or CSP change — the isolation boundary is unchanged; a plugin can only change what its user could change by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host, and the wikis are updated. * docs(plugin): ensure wiki correctness * feat(plugins): plugin metadata on core entities — db:meta (#1429) Plugins can now attach their OWN namespaced key/value data to a trip, place or day without forking the core schema (#1429, request 2). New `db:meta` scope + `ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table (migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only ever sees its own rows. Every call is membership-checked: the entity must belong to a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and recorded in the capability audit. SDK types + mock host, a consent chip + labels in all 22 locales, and the wikis. No new route, no sandbox change. * feat(plugins): place-detail plugin slot in the trip planner (#1429) A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount its sandboxed frame inside the trip planner's place-detail panel, scoped to the open place — the frame receives the `placeId` in trek:context alongside the tripId. This is the UI half of the place-detail-providers ask (reviews/ratings/popular times shown on a place). It reuses the existing widget mechanism: PluginFrame gains an optional placeId, the feed/store learn the new slot, and PlaceInspector renders the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales, wiki updated. No sandbox or permission change. * fix(plugins): green the server tests + harden the new capability surface The in-memory uninstall fixture was missing the new plugin_entity_metadata table, so uninstall's DELETE threw "no such table" and failed the server test job. Add the table to the fixture schema. Self-review hardening of the write/metadata surface: - trips.update now reproduces the web UI's per-field gate: is_archived needs trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a member who may only edit can't archive or re-cover a trip. - Plugin metadata WRITES now also require the entity's edit permission (place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't overwrite or delete metadata another user created. Reads stay access-gated. - Cap the metadata key length (<=256 chars) alongside the value/count quotas — the key was attacker-controlled and uncapped, defeating the disk-DoS guard. * test(plugins): cover the new write/metadata deps to hold the coverage gate The new create-rpc-host write + metadata deps were untested, dropping the src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus mocked core services to exercise every dep end-to-end: places/days/itinerary create/update/delete + not-found paths, trips.update with the archive/cover per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD + key/value/count caps + access checks, the costs deps, and users.getById scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user refusal. Tests only — no production code change. * fix(costs): freeze FX on every cost + settlement write path (#1445) Settled foreign-currency costs kept re-opening with a few-cent residual when live rates drifted. The #1335 freeze only ran on the REST create/ update path, so two gaps remained: - Foreign-currency items created via MCP create_budget_item or booking- import bypassed the freeze and stored exchange_rate = 1, so settlement re-converted them with live rates. Promote freezeForeignRate into the shared budgetService and call it from every write path. - Settle-up transfers were stored currency-less and re-converted with live rates on each recompute. Add currency + exchange_rate to budget_settlements (migration), freeze the display-currency rate at settle time, and convert with it in calculateSettlement. Legacy rows (currency = NULL / rate = 1) keep live-rate behaviour until re-edited. Also expose guarded cost update/delete to plugins: costs.update and costs.delete under db:write:costs, gated exactly like costs.create (addon + trip access + the acting user's budget_edit permission). updateCost reuses BudgetService.update so a plugin write re-freezes the FX rate too; both broadcast the same budget:updated / budget:deleted events the REST controller emits. Wired through the host, the runtime SDK context and the published trek-plugin-sdk (types + mock host). * feat(plugins): provider hooks — placeDetailProvider, wired (#1429) Turn "hooks" from a declared-but-dead surface into a real host→plugin capability. Add an invoke.hook branch to the child + a supervisor hook registry (providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke transport and its timeout (a short 5s deadline so a slow provider can't delay a response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the in-repo runtime SDK copy was missing the `hooks` field entirely and could not even parse a plugin that declared one — synced it with the published SDK. The first wired hook is placeDetailProvider: a plugin returns extra rows ({label,value?,url?}) for a place, and TREK renders them natively at the foot of the place-detail panel. Consumer is a new, additive, fail-safe endpoint GET /api/place-details/:placeId (membership-checked; any provider that errors or times out is simply skipped — it never breaks the panel). New hook:place-detail- provider scope + consent chip in all 22 locales. SDK types (both copies), a controller test, and the wiki. photoProvider/calendarSource stay reserved but the transport now exists for them. No sandbox or CSP change. * fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448) Both bugs were client-only; the server already allows .pkpass and serves it as application/vnd.apple.pkpass. #1448: the reservation/transport attachment inputs hard-coded an accept list that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet MIME types) to the accept attribute in both modals. #1447: the files-tab open path routed every non-media/non-markdown file into the in-app PDF preview object. Add isWalletPass() and route wallet passes through the shared blob openFile helper (as bookings already do), which downloads them so the OS hands them to Apple Wallet. * feat(plugins): validation/warning contributions via warningProvider hook (#1429) Second wired provider hook, reusing the invoke.hook infra from the last commit. A plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message, dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its planned date, missing booking, …). TREK surfaces them as a non-blocking overlay banner at the top of the trip planner (the wrapper ignores pointer events so it never covers the map/panels; only the pills are interactive). Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId (membership-checked; a provider that errors or times out contributes nothing and never blocks the planner). New hook:trip-warning-provider scope + consent chip in all 22 locales, SDK types (both copies), a controller test, and the wiki. This is the validation half of the scheduling+validation block; feeding durations/travel times back into core recalculation stays out (it would touch core planner computation — deliberately deferred to keep the no-breaking-changes guarantee). * fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit) The adversarial audit of the #1429 additions found one real (medium) gap: the hook:* permission was never enforced at runtime. providersOf() selected provider plugins purely by the hooks their CODE declares (sup.hooks, reported by the child as Object.keys(def.hooks)) and never intersected that with sup.granted — so a plugin that merely implemented placeDetailProvider/warningProvider got wired in as a provider even when the admin never consented to hook:place-detail-provider / hook:trip-warning-provider. The downstream capability router still held (the hook's ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an auto-triggered, user-bound execution context on a passive UI browse without the hook being consented — a consent-integrity gap that contradicts the documented invariant. Gate it host-side: a hookName→permission map, and providersOf now returns a plugin only if it is active, implements the hook, AND holds the matching hook:* grant. An unmapped hook resolves to nobody. invokeHook additionally re-checks membership in providersOf (defense-in-depth against a direct caller). Unit test proves the grant/implements/active intersection. * docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco) Fosters plugin authoring by turning the new #1429 capabilities into copy-paste recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an entity with metadata, contribute native place details, raise trip warnings, broadcast, match the TREK look) linked in the sidebar, plus a complete runnable example — trip-doctor — a hooks-only plugin that showcases warningProvider + placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against the SDK. Docs/example only; no product code. * fix(collections): don't reset saved-place status to 'idea' on edit (#1437) The update schema reused collectionStatusSchema, whose .default('idea') survives .optional() — so a PATCH that omits status had 'idea' injected by the validation pipe and written to the DB, clobbering 'want'/'visited'. Strip the default on the update field with .removeDefault(), keeping the .catch guard. Add a shared schema regression test and an e2e round-trip. * docs(plugin): ensure plugin scopes are the same everywhere * feat(plugins): read scopes for packing + files (#1429 eco) Extend the read side of the capability model beyond trips/costs: db:read:packing → ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror the existing trip reads exactly — the host membership-checks the trip against the invocation's user (tripRead) before delegating to the same packingService/ fileService the REST paths use (so bags/assignees hydrate and trash is excluded), and each is a separate scope (packing doesn't unlock files). ctx types in both SDK copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host + create-rpc-host tests, and the wiki (perm table + cookbook recipe). * feat(plugins): core event subscriptions (#1429 eco) A plugin can react to core activity by declaring events: [{ on, handler }] + the events:subscribe grant. websocket.broadcast announces every CORE trip event (name + tripId ONLY, never the payload) through a tiny dependency-free relay (plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor fans each event out to subscribed, granted, active plugins via a fire-and-forget invoke.event on a short timeout — so a slow subscriber can never block a core write. Safety by construction: handlers run with NO user (like a job) so trip reads are refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re- broadcasts are never delivered back, so handlers can't loop; and only the event name + tripId cross the boundary. SDK types (both copies), consent label + cap chip in all 22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook. The relay lives in its own module (not websocket) so it doesn't drag `ws` into the runtime and tests that mock ./websocket don't strip the sink. * feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco) Two author-DX wins, SDK-only. Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list, files.list, costs.*, places/days/itinerary writes and users.getById now return proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors. Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the raw DB row honestly (no column hidden, no false guarantees). mock-host matches. Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a widget builds themed UI with no CSS and no build step. Ships inlined via the same <!-- trek:ui --> marker. Wiki updated. * fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit) The final eco audit found one real (medium) gap: the db:read:packing delegate called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED branch and returns every member's private (is_private=1) packing items — leaking another member's personal/surprise-gift items to a plugin the normal UI/REST hides them from. The handler had the host-bound acting user but dropped it when delegating. Thread it through: tripRead now hands the membership-checked userId to the read callback, packing.list forwards it to listPackingItems(tripId, userId), and the service applies its three-tier #858 filter — a plugin now sees exactly what its user sees. files.list is unaffected (no per-user file visibility). Tests assert the user is passed. The other three audited surfaces (event subscriptions, trek.ui, and the regression sweep of the capability boundary) were clean. * security(plugins): prevent open redirect * fix(plugins): resolve PR #1433 full-audit findings (code + tests) The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own: - ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified the bug — corrected). - Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000). - packing.list / files.list were missing from the capability audit log while every other core read is audited — add them to isAuditable + auditResource. - SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string); the host->plugin boundary is JSON, so align both to string. - Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label. - Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's defense-in-depth grant re-check. Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched). * docs(plugins): correct the wiki against the shipped capability surface (#1433 audit) Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked against the code: - Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not egress[]; dropped the stale "costs.create is the first and only core mutation"; documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta, hook:trip-warning-provider); the widget slot table gained place-detail. - Plugin-Cookbook: days.create no longer passes a title the schema drops; broadcastToUser uses the real (userId, event, data) signature; fixed the broken #the-trek-ui-design-kit anchor and noted window.trek.ui. - Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe; the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx. * fix(unsplash) allow api key usage * fix(guests): scope guest display names per-trip, not globally (#1446) A guest is a per-trip person, but their name lived in the globally UNIQUE users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human name there and gets a uuid-based username that is never shown, and every member view (members list, day-assignment participants, budget members/payers, packing recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename updates display_name with no dedup. Real users are unchanged (display_name NULL → COALESCE falls through to username). Migration adds the nullable column; existing guests keep their current username via the COALESCE fallback. This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects display_name. Tests: two "Jake" guests on two trips both keep the name; the two codified-the-old-behaviour guest tests corrected. * fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445) The full audit found that updateSettlement called freezeForeignRate without the "currency unchanged" guard the item path has, so any edit of a foreign-currency settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the frozen one — re-opening an already-balanced position with a small residual, the exact drift #1445 was meant to prevent. freezeForeignRate's unchanged-check was item-centric (it queried budget_items), which a settlement (a different table) can't use. Give it an explicit existingCurrency param; updateSettlement now reads the settlement's stored currency and passes it, so an edit that doesn't change the currency keeps the frozen rate (the service UPDATE already preserves exchange_rate when it's left unset). Tests cover both: unchanged currency keeps the rate, a real currency change re-freezes. * feat(plugins): add inter plugin dependency support and addon dependency support * feat(plugins): add inter plugin dependency support and addon dependency support * docs(plugins) inter dependencies --------- Co-authored-by: Maurice <mauriceboe@icloud.com> Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
This commit is contained in:
@@ -3419,6 +3419,70 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);');
|
||||
},
|
||||
// Migration 161: plugin-owned metadata on core entities (#1429). A namespaced
|
||||
// key/value store so a plugin can attach data to a trip/place/day WITHOUT
|
||||
// forking the core schema. One row per (plugin, entity, key); a plugin only
|
||||
// ever sees its own rows. entity_type is polymorphic (no cross-table FK), so
|
||||
// rows are purged by plugin_id on uninstall; entity deletes leave harmless
|
||||
// orphans that the plugin's own reads never surface.
|
||||
() => {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS plugin_entity_metadata (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (plugin_id, entity_type, entity_id, key)
|
||||
);`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id);');
|
||||
},
|
||||
|
||||
// Freeze the FX rate on settle-up transfers too (#1445). budget_settlements
|
||||
// stored only a bare `amount` in whatever display currency the payer was
|
||||
// viewing, so a later live-rate drift re-opened an already-settled position
|
||||
// with a few-cent residual. Capture the display `currency` and the rate frozen
|
||||
// at settle time (units of that currency per 1 trip currency), mirroring the
|
||||
// budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on
|
||||
// live rates until re-edited.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE budget_settlements ADD COLUMN currency TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE budget_settlements ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// #1446: guests are per-trip people, but their display name lived in the globally
|
||||
// UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to
|
||||
// "Jake 2". Add a non-unique display_name; new guests store the human name here and
|
||||
// get a uuid-based username that is never shown (the member views COALESCE to it).
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE users ADD COLUMN display_name TEXT');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare
|
||||
// `requiredAddons` (addon ids that must be enabled to activate) and
|
||||
// `pluginDependencies` ({id, version-range} of other plugins that must be
|
||||
// installed + satisfied). Stored as one JSON blob and populated by the
|
||||
// discovery upsert on every install path. Legacy rows default to '{}' (no deps).
|
||||
() => {
|
||||
try {
|
||||
db.exec("ALTER TABLE plugins ADD COLUMN dependencies TEXT NOT NULL DEFAULT '{}'");
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
updateMembers as updateBudgetMembers,
|
||||
toggleMemberPaid, getBudgetItem,
|
||||
toggleMemberPaid, getBudgetItem, freezeForeignRate,
|
||||
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
|
||||
} from '../../services/budgetService';
|
||||
import { getRates } from '../../services/exchangeRateService';
|
||||
@@ -68,7 +68,11 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
|
||||
const members = resolveMemberIds(tripId, member_ids);
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note });
|
||||
const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, note };
|
||||
// Freeze the live FX rate at entry time so a settled position isn't re-opened
|
||||
// when live rates drift (#1445) — same as the REST create path.
|
||||
await freezeForeignRate(tripId, itemData);
|
||||
const item = createBudgetItem(tripId, itemData);
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PluginRuntimeService } from '../plugins/plugin-runtime.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -30,7 +31,10 @@ function ok<T>(result: T): Exclude<T, { error: string }> {
|
||||
@Controller('api/admin')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
constructor(
|
||||
private readonly admin: AdminService,
|
||||
private readonly pluginRuntime: PluginRuntimeService,
|
||||
) {}
|
||||
|
||||
// ── Users ──
|
||||
@Get('users')
|
||||
@@ -271,13 +275,19 @@ export class AdminController {
|
||||
listAddons() { return { addons: this.admin.listAddons() }; }
|
||||
|
||||
@Put('addons/:id')
|
||||
updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
|
||||
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
|
||||
const result = ok(this.admin.updateAddon(id, body));
|
||||
writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||
// Sessions only need re-creating when the registered MCP surface can
|
||||
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
|
||||
// saves and photo-provider toggles used to kill every session (#1414).
|
||||
if (result.mcpAffected) this.admin.invalidateMcpSessions();
|
||||
// Disabling an addon cascades to plugins that require it: stop them (and any
|
||||
// plugin that transitively depends on them) so a plugin never runs against a
|
||||
// disabled addon (#plugins dependencies).
|
||||
if (result.addon && result.addon.enabled === false && (body as { enabled?: boolean })?.enabled === false) {
|
||||
await this.pluginRuntime.deactivateForDisabledAddon(id);
|
||||
}
|
||||
return { addon: result.addon };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PluginsModule } from '../plugins/plugins.module';
|
||||
|
||||
@Module({
|
||||
imports: [PluginsModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { checkPermission } from '../../services/permissions';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { createReservation } from '../../services/reservationService';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { createBudgetItem } from '../../services/budgetService';
|
||||
import { createBudgetItem, freezeForeignRate } from '../../services/budgetService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
@@ -235,13 +235,17 @@ export class BookingImportService {
|
||||
const price = meta && meta.price != null ? Number(meta.price) : NaN;
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
try {
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
const budgetData = {
|
||||
category: typeToCostCategory(item.type),
|
||||
name: item.title,
|
||||
total_price: price,
|
||||
currency: meta && typeof meta.priceCurrency === 'string' ? meta.priceCurrency : null,
|
||||
reservation_id: reservation.id,
|
||||
});
|
||||
};
|
||||
// Freeze the live FX rate for a foreign-currency booking price so a
|
||||
// settled position isn't re-opened when live rates drift (#1445).
|
||||
await freezeForeignRate(tripId, budgetData);
|
||||
const budgetItem = createBudgetItem(tripId, budgetData);
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, socketId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
|
||||
@@ -74,10 +74,10 @@ export class BudgetController {
|
||||
}
|
||||
|
||||
@Post('settlements')
|
||||
createSettlement(
|
||||
async createSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number; currency?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
@@ -85,9 +85,9 @@ export class BudgetController {
|
||||
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||
}
|
||||
const settlement = this.budget.createSettlement(
|
||||
const settlement = await this.budget.createSettlement(
|
||||
tripId,
|
||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount },
|
||||
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency },
|
||||
user.id,
|
||||
);
|
||||
this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId);
|
||||
@@ -95,11 +95,11 @@ export class BudgetController {
|
||||
}
|
||||
|
||||
@Put('settlements/:settlementId')
|
||||
updateSettlement(
|
||||
async updateSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('settlementId') settlementId: string,
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number; currency?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
@@ -107,10 +107,11 @@ export class BudgetController {
|
||||
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||
}
|
||||
const settlement = this.budget.updateSettlement(settlementId, tripId, {
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, {
|
||||
from_user_id: body.from_user_id,
|
||||
to_user_id: body.to_user_id,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
});
|
||||
if (!settlement) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
|
||||
@@ -41,39 +41,13 @@ export class BudgetService {
|
||||
return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||
}
|
||||
|
||||
// Freeze the live FX rate at entry time into budget_items.exchange_rate so a settled
|
||||
// position isn't re-opened when live rates drift later (#1335). Only for a foreign
|
||||
// currency with no explicit rate; degrades to live rates if the fetch fails. On update
|
||||
// it (re)freezes only when the currency changes, so an unrelated edit never moves money.
|
||||
private async freezeForeignRate(
|
||||
tripId: string,
|
||||
data: { currency?: string | null; exchange_rate?: number },
|
||||
existingItemId?: string,
|
||||
): Promise<void> {
|
||||
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
|
||||
const cur = (data.currency || '').toUpperCase();
|
||||
if (!cur) return; // currency not being set in this request
|
||||
if (existingItemId != null) {
|
||||
const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?')
|
||||
.get(existingItemId) as { currency?: string } | undefined;
|
||||
if (existing && (existing.currency || '').toUpperCase() === cur) return; // currency unchanged
|
||||
}
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
|
||||
.get(tripId) as { currency?: string } | undefined;
|
||||
const tripCur = (trip?.currency || 'EUR').toUpperCase();
|
||||
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
|
||||
const rates = await getRates(tripCur);
|
||||
const r = rates?.[cur];
|
||||
if (r && r > 0) data.exchange_rate = r;
|
||||
}
|
||||
|
||||
async create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
||||
await this.freezeForeignRate(tripId, data);
|
||||
await svc.freezeForeignRate(tripId, data);
|
||||
return svc.createBudgetItem(tripId, data);
|
||||
}
|
||||
|
||||
async update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) {
|
||||
await this.freezeForeignRate(tripId, data, id);
|
||||
await svc.freezeForeignRate(tripId, data, id);
|
||||
return svc.updateBudgetItem(id, tripId, data);
|
||||
}
|
||||
|
||||
@@ -97,11 +71,19 @@ export class BudgetService {
|
||||
return svc.listSettlements(tripId);
|
||||
}
|
||||
|
||||
createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }, userId: number) {
|
||||
async createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, userId: number) {
|
||||
// Freeze the FX rate for the display currency the amount was entered in so the
|
||||
// transfer keeps cancelling its expense when live rates drift (#1445).
|
||||
await svc.freezeForeignRate(tripId, data);
|
||||
return svc.createSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) {
|
||||
async updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }) {
|
||||
// Pass the settlement's stored currency so an edit that doesn't change it keeps
|
||||
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
|
||||
// settled position on an unrelated edit.
|
||||
const existing = svc.listSettlements(tripId).find((s) => s.id === Number(id));
|
||||
await svc.freezeForeignRate(tripId, data, undefined, existing?.currency ?? null);
|
||||
return svc.updateSettlement(id, tripId, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,17 @@ export class FilesDownloadController {
|
||||
}
|
||||
|
||||
// Serve Apple Wallet passes inline with the canonical MIME type so Safari
|
||||
// (iOS/macOS) hands them to Wallet instead of downloading as a blob.
|
||||
if (path.extname(resolved).toLowerCase() === '.pkpass') {
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.pkpass');
|
||||
// (iOS/macOS) hands them to Wallet instead of downloading as a blob. A
|
||||
// `.pkpasses` bundle (a ZIP of multiple passes) is a distinct type with its
|
||||
// own plural MIME type — without it Wallet won't offer to add the passes.
|
||||
const walletMime =
|
||||
path.extname(resolved).toLowerCase() === '.pkpass'
|
||||
? 'application/vnd.apple.pkpass'
|
||||
: path.extname(resolved).toLowerCase() === '.pkpasses'
|
||||
? 'application/vnd.apple.pkpasses'
|
||||
: null;
|
||||
if (walletMime) {
|
||||
res.setHeader('Content-Type', walletMime);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import semver from 'semver';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
|
||||
/**
|
||||
* Pure dependency-reasoning helpers for the plugin runtime (#plugins). Kept
|
||||
* side-effect free (no DB, no process) so the activation gate, the addon-disable
|
||||
* cascade, and the registry resolver can all share — and unit-test — the same
|
||||
* logic. The DB layer hands these functions plain rows; they never query.
|
||||
*/
|
||||
|
||||
export interface PluginDependencies {
|
||||
requiredAddons: string[];
|
||||
pluginDependencies: PluginDependency[];
|
||||
}
|
||||
|
||||
/** The subset of a `plugins` row these helpers reason over. */
|
||||
export interface PluginDepRow {
|
||||
id: string;
|
||||
version: string | null;
|
||||
enabled: number;
|
||||
/** The `dependencies` JSON blob column (may be null/legacy '{}'). */
|
||||
dependencies: string | null;
|
||||
}
|
||||
|
||||
/** Thrown when the dependency graph contains a cycle (A → B → A). */
|
||||
export class DependencyCycleError extends Error {
|
||||
constructor(readonly cyclePath: string[]) {
|
||||
super(`plugin dependency cycle: ${cyclePath.join(' -> ')}`);
|
||||
this.name = 'DependencyCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY: PluginDependencies = { requiredAddons: [], pluginDependencies: [] };
|
||||
|
||||
/** Parse the stored `dependencies` JSON blob, tolerating legacy/garbage values. */
|
||||
export function parseDependencies(raw: string | null | undefined): PluginDependencies {
|
||||
if (!raw) return { requiredAddons: [], pluginDependencies: [] };
|
||||
try {
|
||||
const o = JSON.parse(raw) as Partial<PluginDependencies>;
|
||||
return {
|
||||
requiredAddons: Array.isArray(o.requiredAddons) ? o.requiredAddons.filter((a): a is string => typeof a === 'string') : [],
|
||||
pluginDependencies: Array.isArray(o.pluginDependencies)
|
||||
? o.pluginDependencies.filter(
|
||||
(d): d is PluginDependency =>
|
||||
!!d && typeof d === 'object' && typeof (d as PluginDependency).id === 'string' && typeof (d as PluginDependency).version === 'string',
|
||||
)
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY };
|
||||
}
|
||||
}
|
||||
|
||||
/** Required addon ids that are currently NOT enabled — these block activation. */
|
||||
export function disabledRequiredAddons(deps: PluginDependencies, isAddonEnabled: (id: string) => boolean): string[] {
|
||||
return deps.requiredAddons.filter((a) => !isAddonEnabled(a));
|
||||
}
|
||||
|
||||
export interface VersionMismatch {
|
||||
id: string;
|
||||
/** The declared semver range. */
|
||||
wanted: string;
|
||||
/** The installed version that fails the range. */
|
||||
installed: string;
|
||||
}
|
||||
|
||||
export interface DependencyState {
|
||||
/** Declared deps not installed at all. */
|
||||
missing: PluginDependency[];
|
||||
/** Installed but the version is outside the declared range. */
|
||||
versionMismatch: VersionMismatch[];
|
||||
/** Installed + satisfied but currently disabled (auto-enable candidates). */
|
||||
satisfiedDisabled: string[];
|
||||
/** Installed + satisfied + already enabled. */
|
||||
satisfiedActive: string[];
|
||||
}
|
||||
|
||||
/** Classify each declared plugin dependency against what is installed. */
|
||||
export function resolveDependencyState(deps: PluginDependencies, installed: Map<string, PluginDepRow>): DependencyState {
|
||||
const state: DependencyState = { missing: [], versionMismatch: [], satisfiedDisabled: [], satisfiedActive: [] };
|
||||
for (const dep of deps.pluginDependencies) {
|
||||
const row = installed.get(dep.id);
|
||||
if (!row) {
|
||||
state.missing.push(dep);
|
||||
continue;
|
||||
}
|
||||
const installedVersion = row.version ?? '0.0.0';
|
||||
if (!semver.satisfies(installedVersion, dep.version, { includePrerelease: true })) {
|
||||
state.versionMismatch.push({ id: dep.id, wanted: dep.version, installed: installedVersion });
|
||||
continue;
|
||||
}
|
||||
if (row.enabled) state.satisfiedActive.push(dep.id);
|
||||
else state.satisfiedDisabled.push(dep.id);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Plugins whose `pluginDependencies` reference `pluginId` (direct dependents). */
|
||||
export function findDependents(pluginId: string, rows: PluginDepRow[]): string[] {
|
||||
return rows.filter((r) => parseDependencies(r.dependencies).pluginDependencies.some((d) => d.id === pluginId)).map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* All plugins that (transitively) depend on `pluginId`. Used by the addon-disable
|
||||
* cascade: disabling the addon disables the plugins that require it AND everything
|
||||
* downstream of those. Order is leaf-most last is NOT guaranteed; callers only
|
||||
* need the set for deactivation.
|
||||
*/
|
||||
export function findDependentsTransitive(pluginId: string, rows: PluginDepRow[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>([pluginId]);
|
||||
const queue = [pluginId];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift() as string;
|
||||
for (const dep of findDependents(cur, rows)) {
|
||||
if (seen.has(dep)) continue;
|
||||
seen.add(dep);
|
||||
out.push(dep);
|
||||
queue.push(dep);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deps-first activation order for the given root ids over the installed graph.
|
||||
* Edges to plugins that are NOT installed are ignored here (the activation gate
|
||||
* reports those as missing separately). Throws {@link DependencyCycleError} on a
|
||||
* back-edge. Passing multiple roots (boot) yields one global order with each
|
||||
* plugin appearing once, dependencies always before their dependents.
|
||||
*/
|
||||
export function enableOrder(ids: string[], installed: Map<string, PluginDepRow>): string[] {
|
||||
const order: string[] = [];
|
||||
const done = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const visit = (cur: string): void => {
|
||||
if (done.has(cur)) return;
|
||||
const cycleAt = stack.indexOf(cur);
|
||||
if (cycleAt !== -1) throw new DependencyCycleError([...stack.slice(cycleAt), cur]);
|
||||
const row = installed.get(cur);
|
||||
if (!row) return; // not installed — the gate handles it upstream
|
||||
stack.push(cur);
|
||||
for (const dep of parseDependencies(row.dependencies).pluginDependencies) visit(dep.id);
|
||||
stack.pop();
|
||||
done.add(cur);
|
||||
order.push(cur);
|
||||
};
|
||||
for (const id of ids) visit(id);
|
||||
return order;
|
||||
}
|
||||
@@ -1,9 +1,44 @@
|
||||
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 { checkPermission } from '../../../services/permissions';
|
||||
import { listTrips, updateTrip, NotFoundError, ValidationError } from '../../../services/tripService';
|
||||
import { createPlace, updatePlace, deletePlace } from '../../../services/placeService';
|
||||
import { createDay, getDay, updateDay, deleteDay } from '../../../services/dayService';
|
||||
import { createAssignment, deleteAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../services/assignmentService';
|
||||
import { isAddonEnabled } from '../../../services/adminService';
|
||||
import { ADDON_IDS } from '../../../addons';
|
||||
import { BudgetService } from '../../budget/budget.service';
|
||||
import { PluginDataDb } from './plugin-data.service';
|
||||
import { PluginRpcHost } from './rpc-host';
|
||||
import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
|
||||
import { appendAudit } from './plugin-audit';
|
||||
|
||||
/**
|
||||
* The trip-access + role gate used by every planner write, mirroring the app's
|
||||
* per-domain `canEdit` (canAccessTrip + checkPermission for the entity's *_edit
|
||||
* action). Returns false — never throws — so the caller maps it to a clean
|
||||
* RESOURCE_FORBIDDEN.
|
||||
*/
|
||||
function canEditTripAs(action: string, tripId: number, userId: number): boolean {
|
||||
const trip = canAccessTrip(tripId, userId) as { user_id: number } | undefined;
|
||||
if (!trip) return false;
|
||||
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
if (!u) return false;
|
||||
return checkPermission(action, u.role ?? 'user', trip.user_id, userId, trip.user_id !== userId);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
const META_VALUE_MAX = 64 * 1024; // serialized JSON bytes per value
|
||||
const META_KEY_MAX = 256; // key string length (the key is attacker-controlled too)
|
||||
const META_KEYS_MAX = 100; // keys per (plugin, entity)
|
||||
|
||||
/**
|
||||
* Wires a plugin's capability host to the REAL privileged modules (#plugins,
|
||||
* M1). This is the ONLY plugin file that imports db/websocket — it runs in the
|
||||
@@ -27,11 +62,20 @@ export function closePluginDataDb(id: string): void {
|
||||
dataDbs.delete(id);
|
||||
}
|
||||
|
||||
export function createRealRpcHost(id: string, granted: ReadonlySet<string>): PluginRpcHost {
|
||||
/** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */
|
||||
export interface PluginCallRouter {
|
||||
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
|
||||
emitPluginEvent(sourceId: string, event: string, payload: unknown): void;
|
||||
}
|
||||
|
||||
export function createRealRpcHost(id: string, granted: ReadonlySet<string>, router: PluginCallRouter): PluginRpcHost {
|
||||
return new PluginRpcHost(id, granted, {
|
||||
data: getPluginDataDb(id),
|
||||
db,
|
||||
canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId),
|
||||
// The router binds this host's plugin id as the caller/source.
|
||||
callPlugin: (targetId, fn, args, actingUserId) => router.callPlugin(id, targetId, fn, args, actingUserId),
|
||||
emitPluginEvent: (event, payload) => router.emitPluginEvent(id, event, payload),
|
||||
// Two users "share a trip" when both are owner-or-member of the same trip.
|
||||
canSeeUser: (actingUserId, targetUserId) =>
|
||||
!!db
|
||||
@@ -47,5 +91,179 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>): Plu
|
||||
broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload),
|
||||
broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }),
|
||||
audit: (entry) => appendAudit(db, entry),
|
||||
// --- Costs (budget items) ---
|
||||
budgetAddonEnabled: () => isAddonEnabled(ADDON_IDS.BUDGET),
|
||||
// Same gate as a REST/MCP budget mutation: the acting user must have trip
|
||||
// access AND the 'budget_edit' permission for their global role.
|
||||
canEditCosts: (tripId, userId) => {
|
||||
const trip = canAccessTrip(tripId, userId) as { user_id: number } | undefined;
|
||||
if (!trip) return false;
|
||||
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
if (!u) return false;
|
||||
return checkPermission('budget_edit', u.role ?? 'user', trip.user_id, userId, trip.user_id !== userId);
|
||||
},
|
||||
// --- Read scopes (packing/files). Membership is checked by the host (tripRead);
|
||||
// these just delegate to the same services the REST paths use. ---
|
||||
listPackingItems: (tripId, userId) => listPackingItemsSvc(tripId, userId),
|
||||
listTripFiles: (tripId) => listFiles(tripId, false),
|
||||
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.
|
||||
listCostsForUser: (userId) => {
|
||||
const trips = listTrips(userId, null) as Array<{ id: number }>;
|
||||
return trips.flatMap((t) => listBudgetItems(t.id));
|
||||
},
|
||||
// Reuses BudgetService.create (frozen FX + members/payers), then broadcasts
|
||||
// the same 'budget:created' event the controller emits so the web app updates
|
||||
// live. No X-Socket-Id — a plugin has no originating socket.
|
||||
createCost: async (tripId, input) => {
|
||||
const item = await budgetSvc.create(String(tripId), input);
|
||||
broadcast(tripId, 'budget:created', { item });
|
||||
return item;
|
||||
},
|
||||
// Reuses BudgetService.update (re-frozen FX on a currency change), then
|
||||
// broadcasts the same 'budget:updated' event the REST controller emits. A
|
||||
// missing item is a clean RESOURCE_FORBIDDEN (parity with updatePlace).
|
||||
updateCost: async (tripId, itemId, input) => {
|
||||
const item = await budgetSvc.update(String(itemId), String(tripId), input);
|
||||
if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'budget:updated', { item });
|
||||
return item;
|
||||
},
|
||||
// Reuses BudgetService.remove, then broadcasts 'budget:deleted' with the
|
||||
// numeric id — same payload the REST controller sends.
|
||||
deleteCost: (tripId, itemId) => {
|
||||
const deleted = budgetSvc.remove(String(itemId), String(tripId));
|
||||
if (!deleted) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'budget:deleted', { itemId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Places (place_edit). Delegate to the same placeService the REST/MCP paths
|
||||
// use, then broadcast the same events so open web sessions update live. ---
|
||||
canEditPlaces: (tripId, userId) => canEditTripAs('place_edit', tripId, userId),
|
||||
createPlace: (tripId, input) => {
|
||||
const place = createPlace(String(tripId), input as Parameters<typeof createPlace>[1]);
|
||||
broadcast(tripId, 'place:created', { place });
|
||||
return place;
|
||||
},
|
||||
updatePlace: (tripId, placeId, input) => {
|
||||
const place = updatePlace(String(tripId), String(placeId), input as Parameters<typeof updatePlace>[2]);
|
||||
if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'place:updated', { place });
|
||||
return place;
|
||||
},
|
||||
deletePlace: (tripId, placeId) => {
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
broadcast(tripId, 'place:deleted', { placeId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Days (day_edit). getDay scopes the row to the trip before any write. ---
|
||||
canEditDays: (tripId, userId) => canEditTripAs('day_edit', tripId, userId),
|
||||
createDay: (tripId, input) => {
|
||||
const i = input as { date?: string; notes?: string };
|
||||
const day = createDay(tripId, i.date, i.notes);
|
||||
broadcast(tripId, 'day:created', { day });
|
||||
return day;
|
||||
},
|
||||
updateDay: (tripId, dayId, input) => {
|
||||
const current = getDay(dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
const day = updateDay(dayId, current, input as { notes?: string; title?: string | null });
|
||||
broadcast(tripId, 'day:updated', { day });
|
||||
return day;
|
||||
},
|
||||
deleteDay: (tripId, dayId) => {
|
||||
const current = getDay(dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
deleteDay(dayId);
|
||||
broadcast(tripId, 'day:deleted', { dayId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- Itinerary (day_edit). Both the day AND the place must belong to the trip,
|
||||
// so a plugin can't cross-link another trip's rows (assignmentService doesn't
|
||||
// self-check this — the controllers do, so we reproduce it here). ---
|
||||
assignPlaceToDay: (tripId, dayId, placeId, notes) => {
|
||||
if (!dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
if (!placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
const assignment = createAssignment(dayId, placeId, notes);
|
||||
broadcast(tripId, 'assignment:created', { assignment });
|
||||
return assignment;
|
||||
},
|
||||
unassignPlace: (tripId, assignmentId) => {
|
||||
if (!getAssignmentForTrip(assignmentId, tripId)) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
|
||||
deleteAssignment(assignmentId);
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId });
|
||||
return { deleted: true };
|
||||
},
|
||||
// --- 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),
|
||||
updateTrip: (tripId, userId, input) => {
|
||||
// The REST controller gates two fields behind their OWN admin-configurable
|
||||
// permissions, separate from trip_edit — reproduce that here so a plugin (or
|
||||
// its member user) can't archive or re-cover a trip it may only edit.
|
||||
if ('is_archived' in input && !canEditTripAs('trip_archive', tripId, userId)) {
|
||||
throw new ForbiddenResource(`no permission to archive trip ${tripId}`);
|
||||
}
|
||||
if ('cover_image' in input && !canEditTripAs('trip_cover_upload', tripId, userId)) {
|
||||
throw new ForbiddenResource(`no permission to change the cover of trip ${tripId}`);
|
||||
}
|
||||
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
|
||||
try {
|
||||
const result = updateTrip(tripId, userId, input as Parameters<typeof updateTrip>[2], u?.role ?? 'user');
|
||||
broadcast(tripId, 'trip:updated', { trip: result.updatedTrip });
|
||||
return result.updatedTrip;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw new BadParams(e.message);
|
||||
if (e instanceof NotFoundError) throw new ForbiddenResource(e.message);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
// --- 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';
|
||||
return (db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id;
|
||||
},
|
||||
metaGet: (entityType, entityId, key) => {
|
||||
const row = db.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
|
||||
.get(id, entityType, entityId, key) as { value: string } | undefined;
|
||||
if (!row) return null;
|
||||
try { return JSON.parse(row.value); } catch { return null; }
|
||||
},
|
||||
metaSet: (entityType, entityId, key, value) => {
|
||||
if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`);
|
||||
const json = JSON.stringify(value ?? null);
|
||||
if (json.length > META_VALUE_MAX) throw new BadParams(`metadata value too large (>${META_VALUE_MAX} bytes)`);
|
||||
const exists = db.prepare('SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
|
||||
.get(id, entityType, entityId, key);
|
||||
if (!exists) {
|
||||
const { n } = db.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
|
||||
.get(id, entityType, entityId) as { n: number };
|
||||
if (n >= META_KEYS_MAX) throw new BadParams(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`);
|
||||
}
|
||||
db.prepare(`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, entity_type, entity_id, key)
|
||||
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
||||
.run(id, entityType, entityId, key, json);
|
||||
return { key, value: value ?? null };
|
||||
},
|
||||
metaList: (entityType, entityId) => {
|
||||
const list = db.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key')
|
||||
.all(id, entityType, entityId) as Array<{ key: string; value: string }>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const r of list) { try { out[r.key] = JSON.parse(r.value); } catch { out[r.key] = null; } }
|
||||
return out;
|
||||
},
|
||||
metaDelete: (entityType, entityId, key) => {
|
||||
const res = db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
|
||||
.run(id, entityType, entityId, key);
|
||||
return { deleted: res.changes > 0 };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,15 +30,35 @@ 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 === '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.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 === '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. */
|
||||
export function isAuditable(method: string): boolean {
|
||||
return method.startsWith('trips.') || method.startsWith('users.') || method.startsWith('ws.');
|
||||
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.')
|
||||
);
|
||||
}
|
||||
|
||||
/** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
budgetCreateItemRequestSchema, type BudgetCreateItemRequest,
|
||||
budgetUpdateItemRequestSchema, type BudgetUpdateItemRequest,
|
||||
placeCreateRequestSchema, placeUpdateRequestSchema,
|
||||
dayCreateRequestSchema, dayUpdateRequestSchema,
|
||||
tripUpdateRequestSchema,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
KNOWN_METHODS,
|
||||
type KnownMethod,
|
||||
@@ -44,6 +51,53 @@ export interface HostDeps {
|
||||
broadcastToUser(userId: number, payload: Record<string, unknown>): void;
|
||||
/** 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
|
||||
* the dependency edge + the target's `provides` allowlist, forwards the acting user. */
|
||||
callPlugin(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
|
||||
/** Publish an event from this host's plugin to its subscribed dependents. */
|
||||
emitPluginEvent(event: string, payload: unknown): void;
|
||||
/** True when the Costs (budget) addon is enabled — gates all costs.* methods. */
|
||||
budgetAddonEnabled(): boolean;
|
||||
/** True if the acting user may create costs on the trip (the 'budget_edit' permission). */
|
||||
canEditCosts(tripId: number, userId: number): boolean;
|
||||
/** A trip's packing items visible to `userId` (#858 private-item filter), for `packing.list`. */
|
||||
listPackingItems(tripId: number, userId: number): unknown[];
|
||||
/** A trip's files (trash excluded), for `files.list`. */
|
||||
listTripFiles(tripId: 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. */
|
||||
listCostsForUser(userId: number): unknown[];
|
||||
/** Create a budget item on a trip (and broadcast); returns the created item. */
|
||||
createCost(tripId: number, input: BudgetCreateItemRequest): unknown;
|
||||
/** Update a budget item on a trip (and broadcast); returns the updated item. */
|
||||
updateCost(tripId: number, itemId: number, input: BudgetUpdateItemRequest): unknown;
|
||||
/** Delete a budget item from a trip (and broadcast); returns { deleted: true }. */
|
||||
deleteCost(tripId: number, itemId: number): unknown;
|
||||
// --- Places (the 'place_edit' permission) ---
|
||||
canEditPlaces(tripId: number, userId: number): boolean;
|
||||
createPlace(tripId: number, input: Record<string, unknown>): unknown;
|
||||
updatePlace(tripId: number, placeId: number, input: Record<string, unknown>): unknown;
|
||||
deletePlace(tripId: number, placeId: number): unknown;
|
||||
// --- Days + itinerary (the 'day_edit' permission) ---
|
||||
canEditDays(tripId: number, userId: number): boolean;
|
||||
createDay(tripId: number, input: Record<string, unknown>): unknown;
|
||||
updateDay(tripId: number, dayId: number, input: Record<string, unknown>): unknown;
|
||||
deleteDay(tripId: number, dayId: number): unknown;
|
||||
/** Assign a place to a day (both trip-scoped by the wiring); returns the assignment. */
|
||||
assignPlaceToDay(tripId: number, dayId: number, placeId: number, notes: string | null): unknown;
|
||||
/** Remove a day-assignment (trip-scoped by the wiring). */
|
||||
unassignPlace(tripId: number, assignmentId: number): unknown;
|
||||
// --- Trip (the 'trip_edit' permission) ---
|
||||
canEditTrip(tripId: number, userId: number): boolean;
|
||||
updateTrip(tripId: number, userId: number, input: Record<string, unknown>): 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;
|
||||
metaGet(entityType: string, entityId: number, key: string): unknown;
|
||||
metaSet(entityType: string, entityId: number, key: string, value: unknown): unknown;
|
||||
metaList(entityType: string, entityId: number): unknown;
|
||||
metaDelete(entityType: string, entityId: number, key: string): unknown;
|
||||
}
|
||||
|
||||
type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown;
|
||||
@@ -57,7 +111,11 @@ const str = (v: unknown, name: string): string => {
|
||||
if (typeof v !== 'string') throw new BadParams(`${name} must be a string`);
|
||||
return v;
|
||||
};
|
||||
class BadParams extends Error {}
|
||||
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 };
|
||||
|
||||
export class PluginRpcHost {
|
||||
private methods = new Map<string, Handler>();
|
||||
@@ -86,6 +144,183 @@ export class PluginRpcHost {
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
}
|
||||
if (has('db:read:packing')) {
|
||||
// Delegate to the packing service, scoped to the acting user so its #858 private-
|
||||
// item visibility filter applies (a plugin must not see other members' private items).
|
||||
this.methods.set('packing.list', (p, uid) =>
|
||||
this.tripRead(p, uid, (userId) => deps.listPackingItems(num(p.tripId, 'tripId'), userId)),
|
||||
);
|
||||
}
|
||||
if (has('db:read:files')) {
|
||||
// Trip files, trash excluded — same view the files tab shows.
|
||||
this.methods.set('files.list', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.listTripFiles(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
}
|
||||
|
||||
if (has('db:read:costs')) {
|
||||
// "Costs" = budget items (trip-scoped). Same membership gate as trip reads;
|
||||
// additionally requires the Costs addon to be enabled (parity with the app,
|
||||
// where a disabled addon means there is nothing to read).
|
||||
this.methods.set('costs.getByTrip', (p, uid) =>
|
||||
this.tripRead(p, uid, () => {
|
||||
this.requireBudgetAddon();
|
||||
return deps.listCostsForTrip(num(p.tripId, 'tripId'));
|
||||
}),
|
||||
);
|
||||
// Cross-trip aggregate: every cost the acting user can access. The acting
|
||||
// user is host-bound; a job/onLoad (no user) is refused, same as tripRead.
|
||||
this.methods.set('costs.listMine', (p, uid) => {
|
||||
if (uid === undefined) throw new ForbiddenResource('cost reads require an authenticated user context');
|
||||
this.requireBudgetAddon();
|
||||
return deps.listCostsForUser(uid);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:costs')) {
|
||||
// The first plugin path that MUTATES core data. Gate it exactly like a
|
||||
// normal web-app/MCP budget write: addon enabled + trip access + the
|
||||
// 'budget_edit' permission for the host-bound acting user.
|
||||
this.methods.set('costs.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
if (uid === undefined) throw new ForbiddenResource('cost writes require an authenticated user context');
|
||||
this.requireBudgetAddon();
|
||||
const parsed = budgetCreateItemRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid cost: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
if (!this.deps.canEditCosts(tripId, uid)) throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
|
||||
return deps.createCost(tripId, parsed.data);
|
||||
});
|
||||
// Same gate as costs.create — addon + trip access + the acting user's
|
||||
// 'budget_edit' permission — plus the item id. updateCost re-freezes the FX
|
||||
// rate through BudgetService.update exactly like the create path.
|
||||
this.methods.set('costs.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const itemId = num(p.itemId, 'itemId');
|
||||
if (uid === undefined) throw new ForbiddenResource('cost writes require an authenticated user context');
|
||||
this.requireBudgetAddon();
|
||||
const parsed = budgetUpdateItemRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid cost: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
if (!this.deps.canEditCosts(tripId, uid)) throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
|
||||
return deps.updateCost(tripId, itemId, parsed.data);
|
||||
});
|
||||
// Deleting a cost is a budget write too: gated by db:write:costs and, per the
|
||||
// app, the acting user's 'budget_edit' permission on the trip.
|
||||
this.methods.set('costs.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const itemId = num(p.itemId, 'itemId');
|
||||
if (uid === undefined) throw new ForbiddenResource('cost writes require an authenticated user context');
|
||||
this.requireBudgetAddon();
|
||||
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
if (!this.deps.canEditCosts(tripId, uid)) throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
|
||||
return deps.deleteCost(tripId, itemId);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Core planner writes (#1429). Each mirrors costs.create: validate the
|
||||
// input against the SAME @trek/shared schema the web app uses, then gate on
|
||||
// trip access + the entity's edit permission for the HOST-bound acting user
|
||||
// (a job/onLoad has no user, so its writes are refused). The delegating deps
|
||||
// reuse the real services + broadcast the same events, so the app stays live. ---
|
||||
if (has('db:write:places')) {
|
||||
this.methods.set('places.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'place');
|
||||
const parsed = placeCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
|
||||
return deps.createPlace(tripId, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('places.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const placeId = num(p.placeId, 'placeId');
|
||||
const actor = this.requireActor(uid, 'place');
|
||||
const parsed = placeUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
|
||||
return deps.updatePlace(tripId, placeId, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('places.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const placeId = num(p.placeId, 'placeId');
|
||||
const actor = this.requireActor(uid, 'place');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
|
||||
return deps.deletePlace(tripId, placeId);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:days')) {
|
||||
this.methods.set('days.create', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const actor = this.requireActor(uid, 'day');
|
||||
const parsed = dayCreateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.createDay(tripId, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('days.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const dayId = num(p.dayId, 'dayId');
|
||||
const actor = this.requireActor(uid, 'day');
|
||||
const parsed = dayUpdateRequestSchema.safeParse(p.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.updateDay(tripId, dayId, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
this.methods.set('days.delete', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const dayId = num(p.dayId, 'dayId');
|
||||
const actor = this.requireActor(uid, 'day');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.deleteDay(tripId, dayId);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:itinerary')) {
|
||||
// Assigning/removing a place on a day is a DAY edit in the app (day_edit), so
|
||||
// gate it with canEditDays; the wiring also checks the day AND place belong to
|
||||
// the trip so a plugin can't cross-link another trip's rows.
|
||||
this.methods.set('itinerary.assign', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const dayId = num(p.dayId, 'dayId');
|
||||
const placeId = num(p.placeId, 'placeId');
|
||||
const actor = this.requireActor(uid, 'itinerary');
|
||||
const notes = p.notes === undefined || p.notes === null ? null : str(p.notes, 'notes');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.assignPlaceToDay(tripId, dayId, placeId, notes);
|
||||
});
|
||||
this.methods.set('itinerary.unassign', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const assignmentId = num(p.assignmentId, 'assignmentId');
|
||||
const actor = this.requireActor(uid, 'itinerary');
|
||||
this.requireTripEdit(tripId, actor, deps.canEditDays);
|
||||
return deps.unassignPlace(tripId, assignmentId);
|
||||
});
|
||||
}
|
||||
|
||||
if (has('db:write:trips')) {
|
||||
this.methods.set('trips.update', (p, uid) => {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
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.requireTripEdit(tripId, actor, deps.canEditTrip);
|
||||
return deps.updateTrip(tripId, actor, parsed.data as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// ACCESS, so a plugin can't stash/read metadata against another tenant's rows.
|
||||
this.methods.set('meta.get', (p, uid) => { const e = this.metaEntity(p, uid, false); return deps.metaGet(e.entityType, e.entityId, str(p.key, 'key')); });
|
||||
this.methods.set('meta.set', (p, uid) => { const e = this.metaEntity(p, uid, true); return deps.metaSet(e.entityType, e.entityId, str(p.key, 'key'), p.value); });
|
||||
this.methods.set('meta.list', (p, uid) => { const e = this.metaEntity(p, uid, false); return deps.metaList(e.entityType, e.entityId); });
|
||||
this.methods.set('meta.delete', (p, uid) => { const e = this.metaEntity(p, uid, true); return deps.metaDelete(e.entityType, e.entityId, str(p.key, 'key')); });
|
||||
}
|
||||
|
||||
if (has('db:read:users')) {
|
||||
// Scope to people the acting user can actually see (self or a trip they
|
||||
@@ -122,6 +357,18 @@ export class PluginRpcHost {
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// acting user is forwarded so the target's export runs as the caller's user.
|
||||
this.methods.set('plugins.call', (p, uid) =>
|
||||
deps.callPlugin(str(p.targetId, 'targetId'), str(p.fn, 'fn'), p.args, uid),
|
||||
);
|
||||
this.methods.set('events.emit', (p) => {
|
||||
deps.emitPluginEvent(str(p.event, 'event'), p.payload);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +378,7 @@ export class PluginRpcHost {
|
||||
* could set to any id to read another user's trips. If no acting user is bound
|
||||
* (a job / onLoad, or a forged call), the read is forbidden.
|
||||
*/
|
||||
private tripRead(p: Record<string, unknown>, actingUserId: number | undefined, read: () => unknown): unknown {
|
||||
private tripRead(p: Record<string, unknown>, actingUserId: number | undefined, read: (userId: number) => unknown): unknown {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
if (actingUserId === undefined) {
|
||||
throw new ForbiddenResource('trip reads require an authenticated user context');
|
||||
@@ -139,7 +386,71 @@ export class PluginRpcHost {
|
||||
if (!this.deps.canAccessTrip(tripId, actingUserId)) {
|
||||
throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
}
|
||||
return read();
|
||||
// The read runs only for a bound, membership-checked user — hand it through so
|
||||
// per-user visibility filters (e.g. packing's #858 private items) can apply.
|
||||
return read(actingUserId);
|
||||
}
|
||||
|
||||
/** Refuse costs.* calls when the Costs (budget) addon is disabled. */
|
||||
private requireBudgetAddon(): void {
|
||||
if (!this.deps.budgetAddonEnabled()) {
|
||||
throw new ForbiddenResource('the costs addon is disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every write needs a HOST-bound acting user. A job / onLoad (no user) or a call
|
||||
* with a forged/unknown invocation id resolves to undefined and is refused — a
|
||||
* plugin can never write "as" an arbitrary user.
|
||||
*/
|
||||
private requireActor(uid: number | undefined, noun: string): number {
|
||||
if (uid === undefined) throw new ForbiddenResource(`${noun} writes require an authenticated user context`);
|
||||
return uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* The @trek/shared write schemas don't carry the string-length caps the REST
|
||||
* controllers add, so mirror those caps here — otherwise a plugin could write a
|
||||
* field the web app would reject with 400 (e.g. a 100k-char place name).
|
||||
*/
|
||||
private capStrings(input: Record<string, unknown>, limits: Record<string, number>): void {
|
||||
for (const [field, max] of Object.entries(limits)) {
|
||||
const v = input[field];
|
||||
if (typeof v === 'string' && v.length > max) throw new BadParams(`${field} must be ${max} characters or fewer`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A write is allowed only if the acting user can access AND edit the trip. */
|
||||
private requireTripEdit(tripId: number, uid: number, canEdit: (t: number, u: number) => boolean): void {
|
||||
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit trip ${tripId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a metadata target and gate it: the entity type must be one we support,
|
||||
* and the trip it belongs to must be accessible to the host-bound acting user.
|
||||
*/
|
||||
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)`);
|
||||
}
|
||||
const entityId = num(p.entityId, 'entityId');
|
||||
if (uid === undefined) throw new ForbiddenResource('metadata requires an authenticated user context');
|
||||
const tripId = this.deps.metaEntityTrip(entityType, entityId);
|
||||
if (tripId === undefined || !this.deps.canAccessTrip(tripId, uid)) {
|
||||
throw new ForbiddenResource(`no access to ${entityType} ${entityId}`);
|
||||
}
|
||||
// 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).
|
||||
if (write) {
|
||||
const canEdit = entityType === 'trip' ? this.deps.canEditTrip
|
||||
: entityType === 'place' ? this.deps.canEditPlaces
|
||||
: this.deps.canEditDays;
|
||||
if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit ${entityType} ${entityId}`);
|
||||
}
|
||||
return { entityType, entityId };
|
||||
}
|
||||
|
||||
async dispatch(req: RpcRequest, actingUserId?: number): Promise<RpcResponse | RpcError> {
|
||||
|
||||
@@ -41,19 +41,20 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
|
||||
}
|
||||
|
||||
function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
|
||||
const dependencies = JSON.stringify({ requiredAddons: m.requiredAddons, pluginDependencies: m.pluginDependencies });
|
||||
const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
|
||||
min_trek_version = ?, permissions = ?, capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), m.id);
|
||||
min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.id);
|
||||
} else {
|
||||
db.prepare(
|
||||
// granted_permissions '' (empty, not '[]') marks "never consented" so the
|
||||
// first activation is distinguishable from a plugin consented to zero perms.
|
||||
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, granted_permissions, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
|
||||
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities));
|
||||
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, granted_permissions, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
|
||||
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies);
|
||||
}
|
||||
|
||||
// Refresh the settings-field descriptors from the manifest.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import semver from 'semver';
|
||||
import { isKnownPermission } from '../protocol/envelope';
|
||||
|
||||
/**
|
||||
@@ -23,12 +24,23 @@ export interface ManifestSettingField {
|
||||
export interface WidgetCapability {
|
||||
title?: string;
|
||||
defaultSize?: string;
|
||||
/** Where the widget mounts: dashboard sidebar (default) or as a hero-bar overlay. */
|
||||
slot?: 'sidebar' | 'hero';
|
||||
/** 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';
|
||||
}
|
||||
|
||||
export interface PluginCapabilities {
|
||||
widget?: WidgetCapability;
|
||||
/** 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. */
|
||||
emits?: string[];
|
||||
}
|
||||
|
||||
/** A declared dependency on another plugin, pinned by a semver range. */
|
||||
export interface PluginDependency {
|
||||
id: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
@@ -40,7 +52,7 @@ export interface PluginManifest {
|
||||
description?: string;
|
||||
homepage?: string;
|
||||
icon?: string;
|
||||
type: 'integration' | 'page' | 'widget';
|
||||
type: 'integration' | 'page' | 'widget' | 'trip-page';
|
||||
trek?: string;
|
||||
minTrekVersion?: string;
|
||||
nativeModules: boolean;
|
||||
@@ -48,9 +60,17 @@ export interface PluginManifest {
|
||||
egress: string[];
|
||||
settings: ManifestSettingField[];
|
||||
capabilities: PluginCapabilities;
|
||||
/** Addon ids that must be enabled for this plugin to activate (format-only here). */
|
||||
requiredAddons: string[];
|
||||
/** Other plugins that must be installed + version-satisfied to activate. */
|
||||
pluginDependencies: PluginDependency[];
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
// Addon ids are lowercase slugs that may contain underscores (e.g. `llm_parsing`).
|
||||
// Validated format-only: existence is checked at activation, so a plugin declaring
|
||||
// an unknown addon still installs but can never enable (matches "allow install").
|
||||
const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
// An outbound host: an exact hostname (single-label like a `redis` sibling
|
||||
// service, or a dotted FQDN) OR a `*.`-prefixed wildcard that MUST have a real
|
||||
// multi-label suffix. Rejects `*`, `*.`, whole-TLD `*.com`, schemes, and any
|
||||
@@ -61,7 +81,9 @@ const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
|
||||
// (id "registry" would collide with GET registry/:id vs :id/errors routing).
|
||||
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const TYPES = new Set(['integration', 'page', 'widget']);
|
||||
// 'trip-page' mounts the plugin's sandboxed UI as a tab inside every trip planner
|
||||
// (tripId-scoped), with no dashboard presence — unlike 'page' (dashboard nav).
|
||||
const TYPES = new Set(['integration', 'page', 'widget', 'trip-page']);
|
||||
|
||||
export class ManifestError extends Error {}
|
||||
|
||||
@@ -122,9 +144,47 @@ export function parseManifest(raw: unknown): PluginManifest {
|
||||
egress,
|
||||
settings: parseSettings(m.settings),
|
||||
capabilities: parseCapabilities(m.capabilities),
|
||||
requiredAddons: parseRequiredAddons(m.requiredAddons),
|
||||
pluginDependencies: parsePluginDependencies(m.pluginDependencies, id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate `requiredAddons`: a de-duplicated list of well-formed addon-id slugs. */
|
||||
function parseRequiredAddons(raw: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
for (const v of arr(raw)) {
|
||||
if (typeof v !== 'string' || !ADDON_ID_RE.test(v)) throw new ManifestError(`invalid requiredAddons entry "${String(v)}"`);
|
||||
if (!out.includes(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `pluginDependencies`: each `{ id, version }` must reference a valid,
|
||||
* non-self, non-reserved plugin id and a parseable semver range. Duplicate ids
|
||||
* are rejected so a dependency can't be declared with two conflicting ranges.
|
||||
*/
|
||||
function parsePluginDependencies(raw: unknown, selfId: string): PluginDependency[] {
|
||||
const out: PluginDependency[] = [];
|
||||
for (const v of arr(raw)) {
|
||||
if (!v || typeof v !== 'object') throw new ManifestError('each pluginDependencies entry must be an object');
|
||||
const d = v as Record<string, unknown>;
|
||||
const id = str(d.id, 'pluginDependencies.id');
|
||||
if (!ID_RE.test(id)) throw new ManifestError(`invalid pluginDependencies id "${id}"`);
|
||||
if (RESERVED_IDS.has(id)) throw new ManifestError(`reserved pluginDependencies id "${id}"`);
|
||||
if (id === selfId) throw new ManifestError(`plugin "${selfId}" cannot depend on itself`);
|
||||
if (out.some((e) => e.id === id)) throw new ManifestError(`duplicate pluginDependencies id "${id}"`);
|
||||
const version = str(d.version, 'pluginDependencies.version');
|
||||
if (semver.validRange(version) === null) throw new ManifestError(`invalid pluginDependencies version range "${version}" for "${id}"`);
|
||||
out.push({ id, version });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Export function / event names exposed to other plugins. Kept to a safe
|
||||
// identifier shape (dots allowed for event names like `rate.updated`).
|
||||
const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
|
||||
|
||||
function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const c = raw as Record<string, unknown>;
|
||||
@@ -132,13 +192,29 @@ 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') throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
if (slot && slot !== 'sidebar' && slot !== 'hero' && slot !== 'place-detail') throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
out.widget = {
|
||||
title: optStr(w.title),
|
||||
defaultSize: optStr(w.defaultSize),
|
||||
slot: (slot as WidgetCapability['slot']) ?? 'sidebar',
|
||||
};
|
||||
}
|
||||
const provides = parseCapabilityNames(c.provides, 'provides');
|
||||
if (provides.length) out.provides = provides;
|
||||
const emits = parseCapabilityNames(c.emits, 'emits');
|
||||
if (emits.length) out.emits = emits;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate a `provides`/`emits` array: de-duplicated, well-formed names. */
|
||||
function parseCapabilityNames(raw: unknown, field: string): string[] {
|
||||
if (raw === undefined) return [];
|
||||
if (!Array.isArray(raw)) throw new ManifestError(`capabilities.${field} must be an array of names`);
|
||||
const out: string[] = [];
|
||||
for (const v of raw) {
|
||||
if (typeof v !== 'string' || !CAPABILITY_NAME_RE.test(v)) throw new ManifestError(`invalid capabilities.${field} entry "${String(v)}"`);
|
||||
if (!out.includes(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { db, canAccessTrip } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
|
||||
/**
|
||||
* GET /api/place-details/:placeId — extra info for a place, contributed by plugins
|
||||
* that implement the `placeDetailProvider` hook (#1429). Additive and fail-safe:
|
||||
* 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.
|
||||
*/
|
||||
interface ProviderResult {
|
||||
pluginId: string;
|
||||
items: unknown;
|
||||
}
|
||||
|
||||
@Controller('api/place-details')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlaceDetailsController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get(':placeId')
|
||||
async get(
|
||||
@Param('placeId') placeIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ providers: ProviderResult[] }> {
|
||||
if (!pluginsEnabled()) return { providers: [] };
|
||||
const placeId = Number(placeIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(placeId) || userId == null) return { providers: [] };
|
||||
|
||||
// The place must belong to a trip the caller can access — same gate as a read.
|
||||
const row = db.prepare('SELECT trip_id FROM places WHERE id = ?').get(placeId) as { trip_id: number } | undefined;
|
||||
if (!row || !canAccessTrip(row.trip_id, userId)) return { providers: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('placeDetailProvider');
|
||||
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 };
|
||||
} catch {
|
||||
return null; // a slow / failing provider is skipped, never fatal
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { providers: results.filter((r): r is ProviderResult => r !== null) };
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
||||
import semver from 'semver';
|
||||
import { db } from '../../db/database';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { setPluginEventSink } from '../../plugin-event-sink';
|
||||
import { decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
|
||||
import fs from 'node:fs';
|
||||
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 { pluginCodeDir } from './paths';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
import type { VersionMismatch, PluginDepRow } from './dependencies';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
|
||||
|
||||
const HTTP_OUTBOUND = 'http:outbound:';
|
||||
|
||||
@@ -20,6 +27,24 @@ export class PluginConsentRequired extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginDependencyCode = 'ADDON_DISABLED' | 'DEPENDENCY_MISSING';
|
||||
|
||||
/**
|
||||
* Thrown when a plugin can't activate because a required addon is disabled or a
|
||||
* declared plugin dependency is missing / version-mismatched. The controller maps
|
||||
* it to a 409 carrying `code` + `detail` so the admin UI can offer the right fix.
|
||||
*/
|
||||
export class PluginDependencyError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: PluginDependencyCode,
|
||||
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[] } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PluginDependencyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2).
|
||||
* Bridges the DB registry (`plugins` rows) to the process supervisor: activate
|
||||
@@ -28,17 +53,12 @@ export class PluginConsentRequired extends Error {
|
||||
* `active` plugins on startup when the runtime is enabled.
|
||||
*/
|
||||
|
||||
interface PluginRow {
|
||||
id: string;
|
||||
status: string;
|
||||
permissions: string;
|
||||
granted_permissions: string;
|
||||
config: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly supervisor = new PluginSupervisor(createRealRpcHost, {
|
||||
// The rpc-host factory is bound to `this` as the inter-plugin router, so a
|
||||
// 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), {
|
||||
onStatus: (id, status, error) => {
|
||||
db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
status,
|
||||
@@ -59,30 +79,67 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!pluginsEnabled()) return;
|
||||
// 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));
|
||||
// Discover plugins placed on the volume (registers new ones inactive), then
|
||||
// boot the ones an admin had already activated.
|
||||
// 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:
|
||||
// boot must NEVER block app init, even in a context without plugin tables
|
||||
// (e.g. a slimmed-down test app that only imports AdminModule).
|
||||
try {
|
||||
discoverPlugins(db);
|
||||
const installed = this.installedDepRows();
|
||||
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
|
||||
let order: string[];
|
||||
try {
|
||||
order = enableOrder(enabledIds, installed);
|
||||
} catch {
|
||||
// A cycle among enabled plugins — fall back to arbitrary order; each plugin's
|
||||
// own gate still refuses to spawn, so nothing boots into a broken state.
|
||||
order = enabledIds;
|
||||
}
|
||||
for (const id of order) {
|
||||
this.activate(id).catch((e) => {
|
||||
// A plugin whose required addon is off (or a dependency is missing) at boot
|
||||
// must not stay marked enabled — reconcile the row so the UI reflects reality.
|
||||
if (e instanceof PluginDependencyError || e instanceof DependencyCycleError) {
|
||||
this.deactivate(id).catch(() => {});
|
||||
}
|
||||
/* other failures: status is persisted as error by the supervisor hook */
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* discovery must never block boot */
|
||||
}
|
||||
// Boot everything the admin left ENABLED, regardless of the last runtime
|
||||
// status — a crash or a bad deploy set status='error' but must not silently
|
||||
// keep the plugin down forever.
|
||||
const enabled = db.prepare('SELECT id FROM plugins WHERE enabled = 1').all() as Array<{ id: string }>;
|
||||
for (const { id } of enabled) {
|
||||
this.activate(id).catch(() => {
|
||||
/* status is persisted as error by the supervisor hook */
|
||||
});
|
||||
/* discovery/boot must never block app init */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable every plugin that can no longer run now that `addonId` was turned off:
|
||||
* plugins that require the addon, plus everything that (transitively) depends on
|
||||
* them. Called from the admin addon-toggle handler. No-op for an addon no plugin
|
||||
* requires. Returns the ids actually deactivated.
|
||||
*/
|
||||
async deactivateForDisabledAddon(addonId: string): Promise<string[]> {
|
||||
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const directlyAffected = rows
|
||||
.filter((r) => r.enabled && parseDependencies(r.dependencies).requiredAddons.includes(addonId))
|
||||
.map((r) => r.id);
|
||||
const affected = new Set<string>(directlyAffected);
|
||||
for (const id of directlyAffected) for (const dep of findDependentsTransitive(id, rows)) affected.add(dep);
|
||||
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
|
||||
const toDisable = [...affected].filter((id) => enabledById.get(id));
|
||||
for (const id of toDisable) await this.deactivate(id).catch(() => {});
|
||||
return toDisable;
|
||||
}
|
||||
|
||||
/** Re-scan the plugins volume on demand (admin action). */
|
||||
rescan(): { discovered: string[]; skipped: string[] } {
|
||||
return discoverPlugins(db);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
setPluginEventSink(null);
|
||||
await this.supervisor.shutdownAll();
|
||||
}
|
||||
|
||||
@@ -97,8 +154,40 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* update consent dialog), and otherwise throws PluginConsentRequired.
|
||||
*/
|
||||
async activate(id: string, consentWiden = false): Promise<void> {
|
||||
const row = db.prepare('SELECT id, status, permissions, granted_permissions, config FROM plugins WHERE id = ?').get(id) as
|
||||
| PluginRow
|
||||
const installed = this.installedDepRows();
|
||||
// Deps-first order over the installed graph (throws DependencyCycleError on a
|
||||
// cycle). Missing deps aren't in `installed` so they don't appear here — the
|
||||
// per-node gate reports those separately.
|
||||
const order = enableOrder([id], installed);
|
||||
const rootInstalled = installed.has(id);
|
||||
|
||||
// Read-only pre-flight over the whole chain BEFORE mutating anything, so a
|
||||
// blocked dependency never leaves the chain half-activated. Only the target
|
||||
// may consent-widen; dependencies are auto-enabled at their existing grant.
|
||||
const toCheck = rootInstalled ? order : [id];
|
||||
for (const nodeId of toCheck) this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false);
|
||||
|
||||
// Enable dependencies first (skip ones already enabled), then the target.
|
||||
for (const nodeId of order) {
|
||||
if (nodeId !== id && installed.get(nodeId)?.enabled) continue;
|
||||
await this.spawnActivated(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/** All plugin rows projected to what the dependency helpers reason over. */
|
||||
private installedDepRows(): Map<string, PluginDepRow> {
|
||||
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
return new Map(rows.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only activation gate for one plugin — throws (without mutating) if it may
|
||||
* not activate. Checks run most- to least-severe: permission re-consent →
|
||||
* required addon disabled → missing/mismatched plugin dependency.
|
||||
*/
|
||||
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
|
||||
const row = db.prepare('SELECT permissions, granted_permissions, dependencies FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null }
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
@@ -114,10 +203,34 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND));
|
||||
throw new PluginConsentRequired(`plugin ${id} requests new permissions; explicit re-consent is required`, newPermissions, newEgress);
|
||||
}
|
||||
|
||||
const deps = parseDependencies(row.dependencies);
|
||||
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
|
||||
if (disabledAddons.length) {
|
||||
throw new PluginDependencyError(`plugin ${id} requires disabled addon(s): ${disabledAddons.join(', ')}`, 'ADDON_DISABLED', {
|
||||
addons: disabledAddons,
|
||||
});
|
||||
}
|
||||
const state = resolveDependencyState(deps, installed);
|
||||
if (state.missing.length || state.versionMismatch.length) {
|
||||
throw new PluginDependencyError(`plugin ${id} has unmet plugin dependencies`, 'DEPENDENCY_MISSING', {
|
||||
missing: state.missing,
|
||||
versionMismatch: state.versionMismatch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a (pre-validated) plugin enabled and spawn its child. */
|
||||
private async spawnActivated(id: string): Promise<void> {
|
||||
const row = db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; config: string }
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const declared = parseArray(row.permissions).filter(isKnownPermission);
|
||||
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
|
||||
db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
|
||||
const config = decryptConfig(parseObject(row.config));
|
||||
const egress = declared.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length));
|
||||
const egress = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
|
||||
await this.supervisor.activate(id, new Set(declared), config, egress);
|
||||
}
|
||||
|
||||
@@ -127,6 +240,26 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-initiated deactivation: turn `id` off AND every plugin that (transitively)
|
||||
* depends on it — a dependent can't run without its dependency (its ctx.plugins.call
|
||||
* would fail), so it must not be left enabled. Dependents are stopped before the
|
||||
* dependency. Returns every id actually deactivated (dependents first, then `id`).
|
||||
*
|
||||
* This is separate from the low-level `deactivate()` so the internal stop-then-
|
||||
* restart of update()/sideload() never disables a plugin's dependents.
|
||||
*/
|
||||
async deactivateWithDependents(id: string): Promise<string[]> {
|
||||
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
|
||||
// findDependentsTransitive returns nearest-first; reverse so the deepest dependent
|
||||
// (the furthest caller) stops before the plugin it depends on.
|
||||
const dependents = findDependentsTransitive(id, rows).filter((d) => enabledById.get(d)).reverse();
|
||||
const order = [...dependents, id];
|
||||
for (const pid of order) await this.deactivate(pid).catch(() => {});
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a plugin to the registry's latest version with a re-consent gate: the
|
||||
* new version's declared permissions are diffed against what the admin already
|
||||
@@ -166,6 +299,33 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
return { version: res.version, activated: false, newPermissions, newEgress };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload a plugin from an uploaded archive (admin "Upload plugin"). Extracts +
|
||||
* validates into staging first, stops any running child of the same id (its code
|
||||
* dir is about to be replaced, and on Windows the child holds file locks), then
|
||||
* commits it as an INACTIVE sideloaded plugin. Never auto-activates — the admin
|
||||
* re-activates (and re-consents to permissions) explicitly.
|
||||
*/
|
||||
async sideload(bytes: Buffer): Promise<{ id: string; version: string; replaced: boolean }> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const staged = this.registry.stageUpload(bytes);
|
||||
try {
|
||||
const replaced = !!db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id);
|
||||
// Force any replaced plugin INACTIVE before the swap: stop a running child
|
||||
// (it holds file locks and would keep executing stale code) AND clear the
|
||||
// active flag, so replaced code can never keep running — or even show active
|
||||
// — without a fresh activation + permission consent. deactivate() no-ops on
|
||||
// a plugin that isn't running.
|
||||
if (replaced) await this.deactivate(staged.id);
|
||||
this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
|
||||
return { id: staged.id, version: staged.version, replaced };
|
||||
} catch (e) {
|
||||
// A failure before commitUpload leaves staging behind — clean it up.
|
||||
try { fs.rmSync(staged.stagingDir, { recursive: true, force: true }); } catch {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -178,6 +338,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +363,89 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
invoke(id: string, method: string, params: Record<string, unknown>, actingUserId?: number): Promise<unknown> {
|
||||
return this.supervisor.invoke(id, method, params, { actingUserId });
|
||||
}
|
||||
/** Ids of active plugins implementing a provider hook (e.g. 'placeDetailProvider'). */
|
||||
providersOf(hook: string): string[] {
|
||||
return this.supervisor.providersOf(hook);
|
||||
}
|
||||
/**
|
||||
* Ask ONE plugin's provider hook for data (host→plugin). A tighter default
|
||||
* timeout than a route call so a slow provider can't delay the core response;
|
||||
* the acting user is host-bound so any trip read the hook makes is membership-checked.
|
||||
*/
|
||||
invokeHook(id: string, hook: string, fn: string, args: unknown[], actingUserId?: number, timeoutMs = 5000): Promise<unknown> {
|
||||
// Defense-in-depth: only a plugin that both implements the hook AND holds the
|
||||
// hook:* grant (providersOf enforces both) may be invoked, even if a caller
|
||||
// passes an id directly rather than one returned by providersOf.
|
||||
if (!this.supervisor.providersOf(hook).includes(id)) {
|
||||
return Promise.reject(new Error(`plugin ${id} is not a granted provider of ${hook}`));
|
||||
}
|
||||
return this.supervisor.invoke(id, 'invoke.hook', { hook, fn, args }, { actingUserId, timeoutMs });
|
||||
}
|
||||
|
||||
// ── Inter-plugin router (implements PluginCallRouter; #plugins deps) ──────────
|
||||
|
||||
/**
|
||||
* Route `caller`'s ctx.plugins.call to `target`'s export. Authorization is the
|
||||
* dependency edge — the caller must declare `target` as a version-satisfied
|
||||
* `pluginDependency` — plus the target must expose `fn` (declared in its manifest
|
||||
* `capabilities.provides` AND implemented, as reported at load). The acting user
|
||||
* is forwarded, so the target's export runs membership-checked as the caller's user.
|
||||
*/
|
||||
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown> {
|
||||
if (!this.supervisor.isActive(targetId)) {
|
||||
return Promise.reject(new ForbiddenResource(`plugin ${targetId} is not active`));
|
||||
}
|
||||
if (!this.dependsOnSatisfied(callerId, targetId)) {
|
||||
return Promise.reject(new ForbiddenResource(`plugin ${callerId} does not declare ${targetId} as a satisfied dependency`));
|
||||
}
|
||||
if (!this.capabilityList(targetId, 'provides').includes(fn) || !this.supervisor.exportsOf(targetId).includes(fn)) {
|
||||
return Promise.reject(new ForbiddenResource(`plugin ${targetId} does not export "${fn}"`));
|
||||
}
|
||||
return this.supervisor.invoke(targetId, 'invoke.export', { fn, args }, { actingUserId, timeoutMs: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out an event emitted by `source` to every active plugin that (a) subscribed
|
||||
* to `(source, event)` and (b) declares `source` as a satisfied dependency. The
|
||||
* source must declare `event` in its manifest `capabilities.emits`. Fire-and-forget.
|
||||
*/
|
||||
emitPluginEvent(sourceId: string, event: string, payload: unknown): void {
|
||||
if (!this.capabilityList(sourceId, 'emits').includes(event)) {
|
||||
throw new ForbiddenResource(`plugin ${sourceId} does not declare event "${event}"`);
|
||||
}
|
||||
for (const subscriberId of this.supervisor.subscribersOf(sourceId, event)) {
|
||||
if (!this.dependsOnSatisfied(subscriberId, sourceId)) continue;
|
||||
this.supervisor
|
||||
.invoke(subscriberId, 'invoke.pluginEvent', { source: sourceId, event, payload }, { actingUserId: undefined, timeoutMs: 5000 })
|
||||
.catch(() => {
|
||||
/* a subscriber that throws/times out must not affect the emitter or peers */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** True if `caller` declares `target` as a plugin dependency whose range the
|
||||
* installed target version satisfies. */
|
||||
private dependsOnSatisfied(callerId: string, targetId: string): boolean {
|
||||
const caller = db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
|
||||
const target = db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
|
||||
if (!caller || !target) return false;
|
||||
const dep = parseDependencies(caller.dependencies).pluginDependencies.find((d) => d.id === targetId);
|
||||
if (!dep) return false;
|
||||
return semver.satisfies(target.version ?? '0.0.0', dep.version, { includePrerelease: true });
|
||||
}
|
||||
|
||||
/** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */
|
||||
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
|
||||
const row = db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
|
||||
if (!row) return [];
|
||||
try {
|
||||
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
|
||||
const v = c[field];
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray(json: string): string[] {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ActivePlugin {
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
slot: 'sidebar' | 'hero';
|
||||
slot: 'sidebar' | 'hero' | 'place-detail';
|
||||
}
|
||||
|
||||
@Controller('api/plugins')
|
||||
@@ -32,10 +32,11 @@ export class PluginsFeedController {
|
||||
}
|
||||
}
|
||||
|
||||
function slotOf(capabilities: string): 'sidebar' | 'hero' {
|
||||
function slotOf(capabilities: string): 'sidebar' | 'hero' | 'place-detail' {
|
||||
try {
|
||||
const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } };
|
||||
return c.widget?.slot === 'hero' ? 'hero' : 'sidebar';
|
||||
const slot = c.widget?.slot;
|
||||
return slot === 'hero' || slot === 'place-detail' ? slot : 'sidebar';
|
||||
} catch {
|
||||
return 'sidebar';
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
// 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.
|
||||
const REDIRECT_BASE = 'https://trek.invalid';
|
||||
|
||||
/**
|
||||
* Return a plugin redirect `Location` ONLY if it is a same-origin (relative)
|
||||
* target, normalised to path+query+hash; otherwise `null`. Parsing (rather than
|
||||
* regex-matching the raw string) is what makes this safe against the open-redirect
|
||||
* bypasses browsers introduce for special schemes: `\` normalised to `/`, stripped
|
||||
* tab/newline, protocol-relative `//host`, and fully-qualified/other-scheme URLs
|
||||
* all resolve to a different origin and are rejected.
|
||||
*/
|
||||
function toRelativeLocation(loc: unknown): string | null {
|
||||
if (typeof loc !== 'string' || loc === '') return null;
|
||||
try {
|
||||
const u = new URL(loc, REDIRECT_BASE);
|
||||
if (u.origin !== REDIRECT_BASE) return null;
|
||||
return u.pathname + u.search + u.hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/plugins/:pluginId')
|
||||
export class PluginsProxyController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
@@ -82,11 +106,17 @@ export class PluginsProxyController {
|
||||
// redirects and fall through to the normal path.
|
||||
if ([301, 302, 303, 307, 308].includes(status)) {
|
||||
const loc = Object.entries(headers).find(([k]) => k.toLowerCase() === 'location')?.[1];
|
||||
if (typeof loc !== 'string' || !/^\/(?!\/)/.test(loc)) {
|
||||
// Resolve against a throwaway origin and require the result to stay on it:
|
||||
// only a same-origin (relative) target survives. A string check can't do
|
||||
// this safely — browsers normalise `\` to `/` and strip tab/newline for
|
||||
// http(s), so `/\evil.com`, `/<tab>/evil.com`, `//evil.com`, and any
|
||||
// absolute URL all resolve off-origin and must be rejected.
|
||||
const safeLoc = toRelativeLocation(loc);
|
||||
if (!safeLoc) {
|
||||
res.status(502).json({ error: 'Plugin error', detail: 'unsafe redirect target' });
|
||||
return;
|
||||
}
|
||||
res.status(status).setHeader('Location', loc);
|
||||
res.status(status).setHeader('Location', safeLoc);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService, PluginConsentRequired } from './plugin-runtime.service';
|
||||
import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
|
||||
import { DependencyCycleError } from './dependencies';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
@@ -29,8 +31,8 @@ export class PluginsController {
|
||||
}
|
||||
|
||||
@Get('registry')
|
||||
browse() {
|
||||
return this.registry.browse();
|
||||
browse(@Query('refresh') refresh?: string) {
|
||||
return this.registry.browse(refresh === '1' || refresh === 'true');
|
||||
}
|
||||
|
||||
@Get('registry/:id')
|
||||
@@ -44,16 +46,34 @@ export class PluginsController {
|
||||
|
||||
@Post('install')
|
||||
@HttpCode(200)
|
||||
async install(@Body() body: { id?: string; version?: string }) {
|
||||
async install(@Body() body: { id?: string; version?: string; constraint?: string; withDependencies?: boolean }) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
|
||||
try {
|
||||
return await this.registry.install(body.id, body.version);
|
||||
// withDependencies (used by the "resolve missing dependency" admin flow) pulls
|
||||
// the target + its transitive plugin deps, resolving each to its latest
|
||||
// compatible version and reporting addons the admin still has to enable.
|
||||
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
|
||||
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sideload a plugin from an uploaded .zip/.tar.gz (registers INACTIVE). */
|
||||
@Post('upload')
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 + 4096 } }))
|
||||
async upload(@UploadedFile() file?: Express.Multer.File) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!file?.buffer?.length) throw new HttpException({ error: 'no file uploaded' }, 400);
|
||||
try {
|
||||
return await this.runtime.sideload(file.buffer);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'upload failed' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/config')
|
||||
getConfig(@Param('id') id: string) {
|
||||
return { config: this.plugins.getInstanceConfig(id) };
|
||||
@@ -76,6 +96,14 @@ export class PluginsController {
|
||||
if (e instanceof PluginConsentRequired) {
|
||||
throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409);
|
||||
}
|
||||
// Unmet dependency (disabled addon / missing / version-mismatched plugin) —
|
||||
// the UI offers the right fix (enable addon, or download the dependency).
|
||||
if (e instanceof PluginDependencyError) {
|
||||
throw new HttpException({ error: e.message, code: e.code, ...e.detail }, 409);
|
||||
}
|
||||
if (e instanceof DependencyCycleError) {
|
||||
throw new HttpException({ error: e.message, code: 'DEPENDENCY_CYCLE', cyclePath: e.cyclePath }, 409);
|
||||
}
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400);
|
||||
}
|
||||
return { status: this.runtime.isActive(id) ? 'active' : 'error' };
|
||||
@@ -84,7 +112,9 @@ export class PluginsController {
|
||||
@Post(':id/deactivate')
|
||||
@HttpCode(200)
|
||||
async deactivate(@Param('id') id: string) {
|
||||
await this.runtime.deactivate(id);
|
||||
// Cascade: disabling a plugin also disables everything that depends on it (a
|
||||
// dependent can't run without its dependency). The client refresh reflects it.
|
||||
await this.runtime.deactivateWithDependents(id);
|
||||
return { status: 'inactive' };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { PluginsController } from './plugins.controller';
|
||||
import { PluginsFeedController } from './plugins-feed.controller';
|
||||
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 { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
@@ -15,7 +17,10 @@ import { PluginRegistryService } from './registry/registry.service';
|
||||
* widget assets at /plugin-frame/:id/*.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService],
|
||||
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
|
||||
// required addon was just turned off (#plugins dependencies).
|
||||
exports: [PluginRuntimeService],
|
||||
})
|
||||
export class PluginsModule {}
|
||||
|
||||
@@ -3,9 +3,14 @@ import { db } from '../../db/database';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { readAudit } from './host/plugin-audit';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
|
||||
const SECRET_MASK = '••••••••';
|
||||
|
||||
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin';
|
||||
|
||||
/**
|
||||
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed
|
||||
* plugins from the `plugins` registry table and reports whether the runtime is
|
||||
@@ -13,6 +18,23 @@ const SECRET_MASK = '••••••••';
|
||||
* install pipeline and registry fetch land in later milestones.
|
||||
*/
|
||||
|
||||
interface PluginRawRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
version: string | null;
|
||||
status: string;
|
||||
enabled: number;
|
||||
last_error: string | null;
|
||||
reviewed_at: string | null;
|
||||
source_repo: string | null;
|
||||
permissions: string;
|
||||
capabilities: string;
|
||||
dependencies: string | null;
|
||||
}
|
||||
|
||||
export interface PluginListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -29,19 +51,45 @@ export interface PluginListItem {
|
||||
permissions: string;
|
||||
/** Declared capabilities (JSON string) — e.g. widget slot. */
|
||||
capabilities: string;
|
||||
/** Declared dependencies (parsed) — required addons + plugin deps. */
|
||||
dependencies: PluginDependencies;
|
||||
/** Whether this plugin can currently activate, and why not if it can't. */
|
||||
dependencyStatus: PluginDependencyStatus;
|
||||
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
|
||||
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
list(): { enabled: boolean; plugins: PluginListItem[] } {
|
||||
const plugins = db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
permissions, capabilities
|
||||
permissions, capabilities, dependencies
|
||||
FROM plugins
|
||||
ORDER BY sort_order, name`,
|
||||
)
|
||||
.all() as PluginListItem[];
|
||||
.all() as PluginRawRow[];
|
||||
const installed = new Map<string, PluginDepRow>(
|
||||
rows.map((r) => [r.id, { id: r.id, version: r.version, enabled: r.enabled, dependencies: r.dependencies }]),
|
||||
);
|
||||
const plugins: PluginListItem[] = rows.map((r) => {
|
||||
const deps = parseDependencies(r.dependencies);
|
||||
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
|
||||
const state = resolveDependencyState(deps, installed);
|
||||
const dependencyStatus: PluginDependencyStatus = disabledAddons.length
|
||||
? 'addonDisabled'
|
||||
: state.missing.length || state.versionMismatch.length
|
||||
? 'missingPlugin'
|
||||
: 'ok';
|
||||
const { dependencies: _raw, ...rest } = r;
|
||||
return {
|
||||
...rest,
|
||||
dependencies: deps,
|
||||
dependencyStatus,
|
||||
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
|
||||
};
|
||||
});
|
||||
return { enabled: pluginsEnabled(), plugins };
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,26 @@ export const KNOWN_METHODS = [
|
||||
'trips.getById',
|
||||
'trips.getPlaces',
|
||||
'trips.getReservations',
|
||||
'packing.list',
|
||||
'files.list',
|
||||
'costs.getByTrip',
|
||||
'costs.listMine',
|
||||
'costs.create',
|
||||
'costs.update',
|
||||
'costs.delete',
|
||||
'places.create',
|
||||
'places.update',
|
||||
'places.delete',
|
||||
'days.create',
|
||||
'days.update',
|
||||
'days.delete',
|
||||
'itinerary.assign',
|
||||
'itinerary.unassign',
|
||||
'trips.update',
|
||||
'meta.get',
|
||||
'meta.set',
|
||||
'meta.list',
|
||||
'meta.delete',
|
||||
'users.getById',
|
||||
'ws.broadcastToTrip',
|
||||
'ws.broadcastToUser',
|
||||
@@ -67,6 +87,26 @@ export const METHOD_PERMISSION: Record<KnownMethod, string> = {
|
||||
'trips.getById': 'db:read:trips',
|
||||
'trips.getPlaces': 'db:read:trips',
|
||||
'trips.getReservations': 'db:read:trips',
|
||||
'packing.list': 'db:read:packing',
|
||||
'files.list': 'db:read:files',
|
||||
'costs.getByTrip': 'db:read:costs',
|
||||
'costs.listMine': 'db:read:costs',
|
||||
'costs.create': 'db:write:costs',
|
||||
'costs.update': 'db:write:costs',
|
||||
'costs.delete': 'db:write:costs',
|
||||
'places.create': 'db:write:places',
|
||||
'places.update': 'db:write:places',
|
||||
'places.delete': 'db:write:places',
|
||||
'days.create': 'db:write:days',
|
||||
'days.update': 'db:write:days',
|
||||
'days.delete': 'db:write:days',
|
||||
'itinerary.assign': 'db:write:itinerary',
|
||||
'itinerary.unassign': 'db:write:itinerary',
|
||||
'trips.update': 'db:write:trips',
|
||||
'meta.get': 'db:meta',
|
||||
'meta.set': 'db:meta',
|
||||
'meta.list': 'db:meta',
|
||||
'meta.delete': 'db:meta',
|
||||
'users.getById': 'db:read:users',
|
||||
'ws.broadcastToTrip': 'ws:broadcast:trip',
|
||||
'ws.broadcastToUser': 'ws:broadcast:user',
|
||||
@@ -77,10 +117,22 @@ export const KNOWN_PERMISSIONS = [
|
||||
'db:own',
|
||||
'db:read:trips',
|
||||
'db:read:users',
|
||||
'db:read:costs',
|
||||
'db:read:packing',
|
||||
'db:read:files',
|
||||
'db:write:costs',
|
||||
'db:write:places',
|
||||
'db:write:days',
|
||||
'db:write:itinerary',
|
||||
'db:write:trips',
|
||||
'db:meta',
|
||||
'ws:broadcast:trip',
|
||||
'ws:broadcast:user',
|
||||
'hook:photo-provider',
|
||||
'hook:calendar-source',
|
||||
'hook:place-detail-provider',
|
||||
'hook:trip-warning-provider',
|
||||
'events:subscribe',
|
||||
'http:outbound',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import semver from 'semver';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { db } from '../../../db/database';
|
||||
import type { PluginDependency } from '../install/manifest';
|
||||
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
|
||||
import { safeDownload, sha256Matches } from '../install/safe-fetch';
|
||||
import { verifyAuthorSignature } from '../install/verify-signature';
|
||||
@@ -24,6 +26,8 @@ const REGISTRY_URL =
|
||||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
const MANIFEST_MAX_BYTES = 256 * 1024;
|
||||
// Sideload upload ceiling — matches the SDK `pack` limit (50 MB) plus zip overhead.
|
||||
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 + 4096;
|
||||
|
||||
interface RegistryVersion {
|
||||
version: string;
|
||||
@@ -39,6 +43,10 @@ interface RegistryVersion {
|
||||
publishedAt?: string;
|
||||
/** base64 minisign signature (.minisig payload) over the artifact bytes. */
|
||||
signature?: string;
|
||||
/** Addon ids this version requires enabled (mirrors the manifest; #plugins deps). */
|
||||
requiredAddons?: string[];
|
||||
/** Other plugins this version depends on (mirrors the manifest). */
|
||||
pluginDependencies?: PluginDependency[];
|
||||
}
|
||||
export interface RegistryEntry {
|
||||
id: string;
|
||||
@@ -67,6 +75,8 @@ export interface ManifestPreview {
|
||||
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>;
|
||||
license: string | null;
|
||||
icon: string | null;
|
||||
requiredAddons: string[];
|
||||
pluginDependencies: PluginDependency[];
|
||||
}
|
||||
|
||||
let _cache: { data: Registry; expiresAt: number } | null = null;
|
||||
@@ -82,11 +92,20 @@ export class RegistryError extends Error {}
|
||||
|
||||
@Injectable()
|
||||
export class PluginRegistryService {
|
||||
/** Fetch the aggregated registry (cached, soft-fail, stale-serve). */
|
||||
async fetchRegistry(): Promise<Registry> {
|
||||
if (_cache && Date.now() < _cache.expiresAt) return _cache.data;
|
||||
/**
|
||||
* Fetch the aggregated registry (cached, soft-fail, stale-serve). Pass
|
||||
* force=true (the admin "rescan" button) to bypass the 30-min cache and also
|
||||
* bust GitHub's raw CDN edge cache (max-age=300), so a just-merged registry
|
||||
* entry shows up right away instead of up to ~35 min later.
|
||||
*/
|
||||
async fetchRegistry(force = false): Promise<Registry> {
|
||||
if (!force && _cache && Date.now() < _cache.expiresAt) return _cache.data;
|
||||
if (force) _detailCache.clear();
|
||||
try {
|
||||
const resp = await fetch(REGISTRY_URL, { headers: { 'User-Agent': 'TREK-Server' } });
|
||||
const url = force ? `${REGISTRY_URL}${REGISTRY_URL.includes('?') ? '&' : '?'}_=${Date.now()}` : REGISTRY_URL;
|
||||
const headers: Record<string, string> = { 'User-Agent': 'TREK-Server' };
|
||||
if (force) { headers['Cache-Control'] = 'no-cache'; headers.Pragma = 'no-cache'; }
|
||||
const resp = await fetch(url, { headers });
|
||||
if (!resp.ok) throw new Error(`registry ${resp.status}`);
|
||||
const data = (await resp.json()) as Registry;
|
||||
if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry');
|
||||
@@ -98,14 +117,15 @@ export class PluginRegistryService {
|
||||
}
|
||||
|
||||
/** The browse list the admin UI renders (metadata only, no code). */
|
||||
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; screenshotUrl: string | null }>> {
|
||||
const reg = await this.fetchRegistry();
|
||||
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null }>> {
|
||||
const reg = await this.fetchRegistry(force);
|
||||
return reg.plugins.map((p) => {
|
||||
const latest = p.versions[0] ?? null;
|
||||
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,
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -144,6 +164,7 @@ export class PluginRegistryService {
|
||||
reviewedAt: entry.reviewedAt ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
|
||||
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
|
||||
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
|
||||
manifest,
|
||||
};
|
||||
@@ -153,13 +174,42 @@ export class PluginRegistryService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Install a pinned version from the registry. Returns the installed plugin id. */
|
||||
async install(id: string, version?: string): Promise<{ id: string; version: string }> {
|
||||
/**
|
||||
* Resolve which registry version of `id` to install: the highest that satisfies
|
||||
* `constraint` (any version if omitted) AND is compatible with the running TREK
|
||||
* version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the
|
||||
* registry or nothing qualifies. Backs "download the latest compatible version".
|
||||
*/
|
||||
async resolveVersion(id: string, constraint?: string): Promise<RegistryVersion> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
const ver = version ? entry.versions.find((v) => v.version === version) : entry.versions[0];
|
||||
if (!ver) throw new RegistryError(`version ${version ?? 'latest'} not found for ${id}`);
|
||||
const host = hostVersion();
|
||||
const candidates = entry.versions.filter((v) => {
|
||||
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
|
||||
return hostCompatible(v, host);
|
||||
});
|
||||
if (!candidates.length) {
|
||||
throw new RegistryError(
|
||||
constraint
|
||||
? `no version of ${id} satisfies "${constraint}" and this TREK version`
|
||||
: `no compatible version of ${id} for this TREK version`,
|
||||
);
|
||||
}
|
||||
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
}
|
||||
|
||||
/** Install a version from the registry. Returns the installed plugin id + version. */
|
||||
async install(id: string, opts?: { version?: string; constraint?: string }): Promise<{ id: string; version: string }> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
const ver = opts?.version
|
||||
? entry.versions.find((v) => v.version === opts.version)
|
||||
: opts?.constraint
|
||||
? await this.resolveVersion(id, opts.constraint)
|
||||
: entry.versions[0];
|
||||
if (!ver) throw new RegistryError(`version ${opts?.version ?? opts?.constraint ?? 'latest'} not found for ${id}`);
|
||||
|
||||
// 1. SSRF-safe download + 2. sha256 verify
|
||||
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
|
||||
@@ -203,6 +253,85 @@ export class PluginRegistryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `id` (latest-compatible, or the newest matching `constraint`) together
|
||||
* with any of its declared plugin dependencies that aren't installed yet — each
|
||||
* resolved to the newest version satisfying its declared range. Required addons
|
||||
* can't be installed; they're collected and returned so the caller can prompt the
|
||||
* admin to enable them. Cycle-safe. Already-installed plugins are left untouched.
|
||||
*/
|
||||
async installWithDependencies(id: string, constraint?: string): Promise<{ installed: string[]; requiredAddons: string[] }> {
|
||||
const installedNow = new Set((db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id));
|
||||
const done = new Set<string>();
|
||||
const installed: string[] = [];
|
||||
const requiredAddons = new Set<string>();
|
||||
|
||||
const visit = async (pid: string, range: string | undefined, stack: string[]): Promise<void> => {
|
||||
if (done.has(pid)) return;
|
||||
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
|
||||
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
|
||||
if (!installedNow.has(pid)) {
|
||||
await this.install(pid, range ? { constraint: range } : undefined);
|
||||
installed.push(pid);
|
||||
installedNow.add(pid);
|
||||
}
|
||||
done.add(pid);
|
||||
for (const a of ver.requiredAddons ?? []) requiredAddons.add(a);
|
||||
for (const dep of ver.pluginDependencies ?? []) await visit(dep.id, dep.version, [...stack, pid]);
|
||||
};
|
||||
await visit(id, constraint, []);
|
||||
return { installed, requiredAddons: [...requiredAddons] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload step 1: extract + validate an uploaded archive into a staging dir,
|
||||
* WITHOUT touching the live plugin. Returns the manifest id/version + the staged
|
||||
* path so the caller can stop a running child before the swap. Same hard guards
|
||||
* as a registry install (slip/bomb-safe extract, strict manifest, no native
|
||||
* binaries) — only the registry sha256/signature checks are absent, because a
|
||||
* sideload has no registry entry. Throws (and self-cleans staging) on failure.
|
||||
*/
|
||||
stageUpload(bytes: Buffer): { id: string; version: string; root: string; stagingDir: string } {
|
||||
if (bytes.length > MAX_UPLOAD_BYTES) throw new RegistryError('archive exceeds the 50MB limit');
|
||||
const stagingDir = path.join(pluginsDataRoot(), '.staging', `upload-${Date.now()}`);
|
||||
try {
|
||||
extractArchive(bytes, stagingDir);
|
||||
const root = locateManifestDir(stagingDir);
|
||||
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')));
|
||||
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir };
|
||||
} catch (e) {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload step 2: move a staged upload into place and register it INACTIVE as a
|
||||
* sideloaded plugin — source "local:upload", unsigned, not registry-reviewed, so
|
||||
* the UI flags it and offers no auto-update. The caller MUST have stopped any
|
||||
* running child of this id first (the code dir is replaced).
|
||||
*/
|
||||
commitUpload(staged: { id: string; root: string; stagingDir: string }): void {
|
||||
try {
|
||||
const dest = pluginCodeDir(staged.id);
|
||||
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(staged.root, dest);
|
||||
discoverPlugins(db);
|
||||
// Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps
|
||||
// an existing row's status, so replacing a plugin that was active must not
|
||||
// leave the new code marked active — the admin re-activates (and re-consents
|
||||
// to permissions) explicitly.
|
||||
db.prepare("UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?").run(
|
||||
'local:upload', null, null, null, staged.id,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the author signature (if the entry declares a key + the version a
|
||||
* signature) and enforce Trust-On-First-Use on the author key.
|
||||
@@ -237,6 +366,20 @@ function rawFileUrl(repo: string, commitSha: string, file: string): string {
|
||||
return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`;
|
||||
}
|
||||
|
||||
/** The running TREK version (same source as the rest of the app). */
|
||||
function hostVersion(): string {
|
||||
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
|
||||
}
|
||||
|
||||
/** Whether a registry version's host-version bounds admit the running TREK. */
|
||||
function hostCompatible(v: RegistryVersion, host: string): boolean {
|
||||
const h = semver.coerce(host)?.version ?? host;
|
||||
if (!semver.valid(h)) return true; // unparseable host — don't block on compat
|
||||
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(h, v.minTrekVersion)) return false;
|
||||
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(h, v.maxTrekVersion)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerant projection of a live manifest for display. Unknown shapes degrade to
|
||||
* empty lists instead of throwing — a future manifest field must never break
|
||||
@@ -258,12 +401,20 @@ function previewManifest(raw: unknown): ManifestPreview {
|
||||
}))
|
||||
.filter((s) => s.key)
|
||||
: [];
|
||||
const pluginDependencies = Array.isArray(m.pluginDependencies)
|
||||
? m.pluginDependencies
|
||||
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object')
|
||||
.map((d) => ({ id: typeof d.id === 'string' ? d.id : '', version: typeof d.version === 'string' ? d.version : '' }))
|
||||
.filter((d) => d.id && d.version)
|
||||
: [];
|
||||
return {
|
||||
permissions: strings(m.permissions),
|
||||
egress: strings(m.egress),
|
||||
settings,
|
||||
license: typeof m.license === 'string' ? m.license : null,
|
||||
icon: typeof m.icon === 'string' ? m.icon : null,
|
||||
requiredAddons: strings(m.requiredAddons),
|
||||
pluginDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,20 @@ const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown';
|
||||
const pluginDir = process.argv[3] || '';
|
||||
|
||||
let pluginConfig: Record<string, unknown> = {};
|
||||
// Guards `init` against re-entry (see the message handler). Set the first time a
|
||||
// legitimate `init` arrives from the host.
|
||||
let initialized = false;
|
||||
|
||||
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
let seq = 0;
|
||||
|
||||
// Capture the raw IPC write ONCE, before installIpcGuard() locks `process.send`
|
||||
// to a throwing stub. The transport keeps sending through this closure; plugin
|
||||
// code no longer has any path to the channel. See installIpcGuard().
|
||||
const realSend: (msg: Envelope) => void =
|
||||
typeof process.send === 'function' ? process.send.bind(process) : () => {};
|
||||
function send(msg: Envelope): void {
|
||||
process.send?.(msg);
|
||||
realSend(msg);
|
||||
}
|
||||
|
||||
const transport: ChildTransport = {
|
||||
@@ -73,6 +81,10 @@ function installSdkInjection(requirePlugin: NodeJS.Require): void {
|
||||
|
||||
async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
// Seal the raw IPC surface BEFORE any plugin code is required — this is the
|
||||
// first moment untrusted code runs (requirePlugin below). After this, the
|
||||
// plugin can only reach the host through the capability-checked SDK.
|
||||
installIpcGuard();
|
||||
// createRequire works whether this bootstrap runs as CJS (prod dist) or ESM
|
||||
// (tsx in tests), so `require` being undefined in ESM never bites us.
|
||||
const entry = path.join(pluginDir, 'server', 'index.js');
|
||||
@@ -86,7 +98,13 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
// host can proxy HTTP and schedule 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);
|
||||
send({ k: 'evt', topic: 'loaded', data: { routes, jobs } });
|
||||
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
|
||||
// other-plugin events it subscribes to (so the host can route fan-out).
|
||||
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 } });
|
||||
// An immediate first heartbeat confirms liveness without waiting a full interval.
|
||||
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
|
||||
} catch (e) {
|
||||
@@ -120,6 +138,50 @@ 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.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-
|
||||
// invocation ctx (so any trip reads it makes bind to the authenticated user).
|
||||
const hookName = req.params.hook as string;
|
||||
const fnName = req.params.fn as string;
|
||||
const args = (req.params.args as unknown[]) ?? [];
|
||||
const hooks = def.hooks as Record<string, Record<string, (...a: unknown[]) => unknown> | undefined> | undefined;
|
||||
const impl = hooks?.[hookName];
|
||||
if (!impl || typeof impl[fnName] !== 'function') throw new Error(`no hook ${hookName}.${fnName}`);
|
||||
const result = await impl[fnName](...args, invCtx);
|
||||
respond(true, result);
|
||||
} else if (req.method === 'invoke.event') {
|
||||
// A core event fired for a trip. Run every matching subscription. invCtx carries
|
||||
// NO user (delivered like a job), so trip reads are refused — the handler reacts
|
||||
// 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 };
|
||||
for (const sub of def.events ?? []) {
|
||||
if (sub.on === '*' || sub.on === eventName) await sub.handler(payload, invCtx);
|
||||
}
|
||||
respond(true, { ok: true });
|
||||
} else if (req.method === 'invoke.export') {
|
||||
// Another plugin (a declared dependent) called one of our exports. The host has
|
||||
// already authorized the caller + the export name; run it with the per-invocation
|
||||
// ctx so any trip reads bind to the CALLER's acting user (propagated by the host).
|
||||
const fnName = req.params.fn as string;
|
||||
const args = req.params.args;
|
||||
const impl = (def.exports ?? {})[fnName];
|
||||
if (typeof impl !== 'function') throw new Error(`no export ${fnName}`);
|
||||
const result = await impl(args, invCtx);
|
||||
respond(true, result);
|
||||
} else if (req.method === 'invoke.pluginEvent') {
|
||||
// Another plugin (a declared dependency of ours) emitted an event we subscribed
|
||||
// to. Run every matching subscription. invCtx carries NO user (fire-and-forget),
|
||||
// like a core event, but delivers the emitter's payload.
|
||||
const source = req.params.source as string;
|
||||
const eventName = req.params.event as string;
|
||||
const payload = req.params.payload;
|
||||
for (const sub of def.subscriptions ?? []) {
|
||||
if (sub.plugin === source && sub.event === eventName) await sub.handler(payload, invCtx);
|
||||
}
|
||||
respond(true, { ok: true });
|
||||
} else {
|
||||
respond(false, `unknown invoke ${req.method}`);
|
||||
}
|
||||
@@ -160,6 +222,11 @@ process.on('message', (raw: unknown) => {
|
||||
}
|
||||
if (msg.k === 'evt') {
|
||||
if (msg.topic === 'init') {
|
||||
// One-shot: the host sends `init` exactly once. Refuse any second `init`
|
||||
// so a forged one (e.g. a pre-seal race) can't re-run installEgressGuard
|
||||
// with a widened egress list or re-boot the plugin.
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
const d = msg.data as { config?: Record<string, unknown>; egress?: string[] };
|
||||
pluginConfig = d.config ?? {};
|
||||
installEgressGuard(d.egress ?? []);
|
||||
@@ -168,6 +235,82 @@ process.on('message', (raw: unknown) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sever the raw host<->child IPC surface from plugin code. The plugin runs in
|
||||
* THIS process and shares the global `process`, so without this it could bypass
|
||||
* the SDK entirely and talk to the host directly:
|
||||
* - `process.send(...)` — forge lifecycle/RPC envelopes (fake `loaded`/`heartbeat`,
|
||||
* or a `req` carrying another invocation's `_inv` to act as that user).
|
||||
* - `process.on('message', ...)` — eavesdrop on every host→child `req`/`res`
|
||||
* (other invocations' request bodies, user objects, DB results).
|
||||
* - `process.removeAllListeners('message')` — kill the trusted handler and hijack.
|
||||
*
|
||||
* We do NOT override `process.emit`: Node delivers inbound IPC by calling
|
||||
* `process.emit('message', ...)` internally, so a throwing override would break
|
||||
* message delivery to the trusted listener. A plugin synthesizing
|
||||
* `process.emit('message', forged)` is instead defanged elsewhere — a forged
|
||||
* `init` is ignored by the one-shot `initialized` guard, and a forged `req`/`res`/
|
||||
* `shutdown` only affects the plugin's own process (self-harm, no host impact).
|
||||
*
|
||||
* The transport keeps working because `send()` uses the captured `realSend`
|
||||
* closure, and the trusted message listener + crash handlers are already
|
||||
* registered at module-eval (before this runs). Mirrors the `process.binding`
|
||||
* lock in installEgressGuard: same `Object.defineProperty(..., writable:false,
|
||||
* configurable:false)` idiom, same "already locked" swallow. This is a JS-level
|
||||
* defense-in-depth layer; in prod the OS `--permission` model is the real jail.
|
||||
*
|
||||
* MUST run once, before the plugin module is required.
|
||||
*/
|
||||
function installIpcGuard(): void {
|
||||
const lock = (obj: object, key: string, value: unknown): void => {
|
||||
try {
|
||||
Object.defineProperty(obj, key, { value, writable: false, configurable: false });
|
||||
} catch {
|
||||
/* already locked / non-configurable */
|
||||
}
|
||||
};
|
||||
|
||||
// Outbound: kill the raw write and channel teardown. `realSend` (captured
|
||||
// above) is unaffected — it holds the bound original, not `process.send`.
|
||||
lock(process, 'send', () => {
|
||||
throw new Error('ipc: process.send is disabled for plugins');
|
||||
});
|
||||
lock(process, 'disconnect', () => {
|
||||
throw new Error('ipc: process.disconnect is disabled for plugins');
|
||||
});
|
||||
// NOTE: we intentionally do NOT lock `process.channel`/`process._channel`.
|
||||
// Node's own outbound send (`realSend`, the bound original) reads the pipe
|
||||
// handle back off `process`, so overriding it to `undefined` breaks the
|
||||
// legitimate transport (verified: child→host RPC hangs). Without a working
|
||||
// `process.send`, framing raw IPC through the handle directly is impractical,
|
||||
// so leaving it readable costs nothing.
|
||||
|
||||
// Inbound: the trusted 'message' listener is already installed. Forbid the
|
||||
// plugin adding/removing/replacing a 'message' listener so it can't eavesdrop
|
||||
// on host→child traffic or unhook the trusted handler. Only 'message'/
|
||||
// 'internalMessage' are gated — plugins may still use other process events
|
||||
// (e.g. 'SIGTERM', 'beforeExit'). `emit` is deliberately NOT gated (Node uses
|
||||
// it to deliver inbound IPC; see the header comment).
|
||||
const GUARDED = new Set(['message', 'internalMessage']);
|
||||
const ee = process as unknown as Record<string, (...a: unknown[]) => unknown>;
|
||||
const emitterMethods = [
|
||||
'on', 'addListener', 'prependListener', 'once', 'prependOnceListener',
|
||||
'off', 'removeListener', 'removeAllListeners',
|
||||
] as const;
|
||||
for (const m of emitterMethods) {
|
||||
const real = ee[m].bind(process);
|
||||
lock(process, m, (event: unknown, ...rest: unknown[]) => {
|
||||
// `removeAllListeners()` with no event would also strip the trusted
|
||||
// 'message' listener (and the crash handlers), so block the bare form.
|
||||
const bareRemoveAll = m === 'removeAllListeners' && event === undefined;
|
||||
if (bareRemoveAll || (typeof event === 'string' && GUARDED.has(event))) {
|
||||
throw new Error(`ipc: process.${m}('${String(event)}') is disabled for plugins`);
|
||||
}
|
||||
return real(event, ...rest);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the plugin's outbound network to its declared egress hosts. With no
|
||||
* declared egress, ALL outbound is blocked. Wildcards like `*.host` match any
|
||||
|
||||
@@ -28,6 +28,54 @@ export interface PluginContext {
|
||||
getById(tripId: number, asUserId?: number): Promise<unknown>;
|
||||
getPlaces(tripId: number, asUserId?: number): Promise<unknown[]>;
|
||||
getReservations(tripId: number, asUserId?: number): 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>;
|
||||
};
|
||||
// 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[]>;
|
||||
};
|
||||
files: {
|
||||
/** A trip's files, trash excluded. Needs 'db:read:files'. */
|
||||
list(tripId: number): Promise<unknown[]>;
|
||||
};
|
||||
// "Costs" = budget items. Reads are membership-checked against the current
|
||||
// invocation's user (like `trips`); `create` additionally needs the acting
|
||||
// user's 'budget_edit' permission and the Costs addon enabled.
|
||||
costs: {
|
||||
getByTrip(tripId: number): Promise<unknown[]>;
|
||||
listMine(): Promise<unknown[]>;
|
||||
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
// Core planner writes (#1429). Each is membership-checked against the current
|
||||
// invocation's user and needs the matching write scope + the app's edit
|
||||
// permission (place_edit / day_edit / trip_edit). Route context only.
|
||||
places: {
|
||||
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
update(tripId: number, placeId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
delete(tripId: number, placeId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
days: {
|
||||
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
update(tripId: number, dayId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
delete(tripId: number, dayId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
itinerary: {
|
||||
assign(tripId: number, dayId: number, placeId: number, notes?: string | null): Promise<unknown>;
|
||||
unassign(tripId: number, assignmentId: number): Promise<{ deleted: boolean }>;
|
||||
};
|
||||
// Your OWN namespaced key/value store attached to a trip/place/day (#1429), so
|
||||
// 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 }>;
|
||||
};
|
||||
users: {
|
||||
getById(id: number): Promise<unknown>;
|
||||
@@ -41,6 +89,16 @@ export interface PluginContext {
|
||||
warn(msg: string, meta?: Record<string, unknown>): void;
|
||||
error(msg: string, meta?: Record<string, unknown>): void;
|
||||
};
|
||||
/** Call a function another plugin exposes (must be a declared, satisfied dependency
|
||||
* that lists `fn` in its manifest `capabilities.provides`). Runs as the current user. */
|
||||
plugins: {
|
||||
call(pluginId: string, fn: string, args?: unknown): Promise<unknown>;
|
||||
};
|
||||
/** Publish an event to dependents that subscribed to it (must be declared in this
|
||||
* plugin's manifest `capabilities.emits`). Fire-and-forget. */
|
||||
events: {
|
||||
emit(name: string, payload?: unknown): void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginRequest {
|
||||
@@ -66,12 +124,64 @@ export interface PluginJob {
|
||||
schedule: string;
|
||||
handler(ctx: PluginContext): Promise<void>;
|
||||
}
|
||||
// ── Provider hooks (host→plugin): core asks a hook the plugin implements for data,
|
||||
// gated by the matching hook:* permission. Each method also receives the per-
|
||||
// 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>;
|
||||
}
|
||||
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[]>;
|
||||
}
|
||||
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
|
||||
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
|
||||
export interface PlaceDetailProvider {
|
||||
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
|
||||
}
|
||||
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
|
||||
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
|
||||
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'. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** A function this plugin exposes to its dependents (declared in capabilities.provides). */
|
||||
export type PluginExport = (args: unknown, ctx: PluginContext) => Promise<unknown> | unknown;
|
||||
|
||||
/** A subscription to another plugin's event. Authorized by declaring that plugin as a
|
||||
* dependency; the handler runs with NO user and receives the emitter's payload. */
|
||||
export interface PluginSubscription {
|
||||
plugin: string;
|
||||
event: string;
|
||||
handler(payload: unknown, ctx: PluginContext): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginDefinition {
|
||||
onLoad?(ctx: PluginContext): Promise<void> | void;
|
||||
onUnload?(ctx: PluginContext): Promise<void> | void;
|
||||
routes?: PluginRoute[];
|
||||
jobs?: PluginJob[];
|
||||
// hooks (photo/calendar) land in M2
|
||||
events?: PluginEventSubscription[];
|
||||
hooks?: {
|
||||
photoProvider?: PhotoProvider;
|
||||
calendarSource?: CalendarSource;
|
||||
placeDetailProvider?: PlaceDetailProvider;
|
||||
warningProvider?: WarningProvider;
|
||||
};
|
||||
/** Functions exposed to dependents (names must match manifest capabilities.provides). */
|
||||
exports?: Record<string, PluginExport>;
|
||||
/** Subscriptions to other plugins' events (each `plugin` must be a declared dependency). */
|
||||
subscriptions?: PluginSubscription[];
|
||||
}
|
||||
|
||||
/** Identity helper: gives authors types + a stable shape. A plain object works too. */
|
||||
@@ -110,9 +220,43 @@ export function createPluginContext(
|
||||
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[]>,
|
||||
update: (tripId, input) => t.rpc('trips.update', { tripId, input, _inv: invocationId }),
|
||||
},
|
||||
packing: {
|
||||
list: (tripId) => t.rpc('packing.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
},
|
||||
files: {
|
||||
list: (tripId) => t.rpc('files.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
},
|
||||
costs: {
|
||||
getByTrip: (tripId) => t.rpc('costs.getByTrip', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
listMine: () => t.rpc('costs.listMine', { _inv: invocationId }) as Promise<unknown[]>,
|
||||
create: (tripId, input) => t.rpc('costs.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, itemId, input) => t.rpc('costs.update', { tripId, itemId, input, _inv: invocationId }),
|
||||
delete: (tripId, itemId) => t.rpc('costs.delete', { tripId, itemId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
places: {
|
||||
create: (tripId, input) => t.rpc('places.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, placeId, input) => t.rpc('places.update', { tripId, placeId, input, _inv: invocationId }),
|
||||
delete: (tripId, placeId) => t.rpc('places.delete', { tripId, placeId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
days: {
|
||||
create: (tripId, input) => t.rpc('days.create', { tripId, input, _inv: invocationId }),
|
||||
update: (tripId, dayId, input) => t.rpc('days.update', { tripId, dayId, input, _inv: invocationId }),
|
||||
delete: (tripId, dayId) => t.rpc('days.delete', { tripId, dayId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
itinerary: {
|
||||
assign: (tripId, dayId, placeId, notes) => t.rpc('itinerary.assign', { tripId, dayId, placeId, notes, _inv: invocationId }),
|
||||
unassign: (tripId, assignmentId) => t.rpc('itinerary.unassign', { tripId, assignmentId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
meta: {
|
||||
get: (entityType, entityId, key) => t.rpc('meta.get', { entityType, entityId, key, _inv: invocationId }),
|
||||
set: (entityType, entityId, key, value) => t.rpc('meta.set', { entityType, entityId, key, value, _inv: invocationId }),
|
||||
list: (entityType, entityId) => t.rpc('meta.list', { entityType, entityId, _inv: invocationId }) as Promise<Record<string, unknown>>,
|
||||
delete: (entityType, entityId, key) => t.rpc('meta.delete', { entityType, entityId, key, _inv: invocationId }) as Promise<{ deleted: boolean }>,
|
||||
},
|
||||
users: {
|
||||
getById: (uid) => t.rpc('users.getById', { id: uid }),
|
||||
getById: (uid) => t.rpc('users.getById', { id: uid, _inv: invocationId }),
|
||||
},
|
||||
ws: {
|
||||
broadcastToTrip: async (tripId, event, data) => {
|
||||
@@ -127,5 +271,11 @@ export function createPluginContext(
|
||||
warn: (msg, meta) => t.emit('log', { level: 'warn', msg, meta }),
|
||||
error: (msg, meta) => t.emit('log', { level: 'error', msg, meta }),
|
||||
},
|
||||
plugins: {
|
||||
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 }); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ interface Supervised {
|
||||
lastRss: number; // last reported resident set size (bytes)
|
||||
routes: PluginRouteInfo[];
|
||||
jobs: string[];
|
||||
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)
|
||||
activation?: { resolve: () => void; reject: (e: Error) => void };
|
||||
@@ -80,6 +84,17 @@ const DEFAULTS: Required<SupervisorTuning> = {
|
||||
maxRssBytes: (Number(process.env.TREK_PLUGIN_MAX_RSS_MB) || 300) * 1024 * 1024,
|
||||
};
|
||||
|
||||
// A plugin may only act as a provider for a hook it BOTH implements (reported by
|
||||
// the child at load) AND was granted the matching hook:* permission for. The child
|
||||
// reports Object.keys(def.hooks) with no knowledge of grants, so the grant check
|
||||
// must happen host-side here — otherwise the hook:* consent is never enforced.
|
||||
const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
photoProvider: 'hook:photo-provider',
|
||||
calendarSource: 'hook:calendar-source',
|
||||
placeDetailProvider: 'hook:place-detail-provider',
|
||||
warningProvider: 'hook:trip-warning-provider',
|
||||
};
|
||||
|
||||
export class PluginSupervisor {
|
||||
private running = new Map<string, Supervised>();
|
||||
private sweep: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -109,6 +124,10 @@ export class PluginSupervisor {
|
||||
lastRss: 0,
|
||||
routes: [],
|
||||
jobs: [],
|
||||
hooks: [],
|
||||
events: [],
|
||||
exports: [],
|
||||
subscriptions: [],
|
||||
pending: new Map(),
|
||||
invocations: new Map(),
|
||||
};
|
||||
@@ -164,6 +183,52 @@ export class PluginSupervisor {
|
||||
return this.running.get(id)?.routes ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of ACTIVE plugins that may act as a given provider hook — they implement it
|
||||
* (reported at load) AND hold the matching hook:* grant the admin consented to.
|
||||
* An unknown hook, or one with no permission mapping, resolves to nobody.
|
||||
*/
|
||||
providersOf(hook: string): string[] {
|
||||
const perm = HOOK_PERMISSION[hook];
|
||||
if (!perm) return [];
|
||||
const out: string[] = [];
|
||||
for (const [id, sup] of this.running) {
|
||||
if (sup.status === 'active' && sup.hooks.includes(hook) && sup.granted.has(perm)) out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Callable export names an ACTIVE plugin reported at load (ctx.plugins.call target). */
|
||||
exportsOf(id: string): string[] {
|
||||
const sup = this.running.get(id);
|
||||
return sup && sup.status === 'active' ? sup.exports : [];
|
||||
}
|
||||
|
||||
/** Ids of ACTIVE plugins that subscribed to `event` emitted by `sourceId`. */
|
||||
subscribersOf(sourceId: string, event: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [id, sup] of this.running) {
|
||||
if (sup.status === 'active' && sup.subscriptions.some((s) => s.plugin === sourceId && s.event === event)) out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
deliverEvent(tripId: number, event: string): void {
|
||||
for (const [id, sup] of this.running) {
|
||||
if (sup.status !== 'active' || !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 */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a host→child request (invoke a route or job) and await its response.
|
||||
* `actingUserId` binds the authenticated user of this invocation on the HOST
|
||||
@@ -279,10 +344,22 @@ 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;
|
||||
sup.lastBeat = Date.now();
|
||||
const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: string[] };
|
||||
const d = msg.data as {
|
||||
routes?: PluginRouteInfo[]; jobs?: string[]; hooks?: string[]; events?: string[];
|
||||
exports?: string[]; subscriptions?: Array<{ plugin: string; event: string }>;
|
||||
};
|
||||
sup.routes = d.routes ?? [];
|
||||
sup.jobs = d.jobs ?? [];
|
||||
sup.hooks = d.hooks ?? [];
|
||||
sup.events = d.events ?? [];
|
||||
sup.exports = Array.isArray(d.exports) ? d.exports.filter((e): e is string => typeof e === 'string') : [];
|
||||
sup.subscriptions = Array.isArray(d.subscriptions)
|
||||
? d.subscriptions.filter((s): s is { plugin: string; event: string } => !!s && typeof s.plugin === 'string' && typeof s.event === 'string')
|
||||
: [];
|
||||
this.clearActivationTimer(sup);
|
||||
this.setStatus(sup, 'active');
|
||||
sup.activation?.resolve();
|
||||
@@ -305,7 +382,13 @@ export class PluginSupervisor {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.hooks.onEvent?.(sup.id, msg.topic, msg.data);
|
||||
// Strict inbound whitelist: the legitimate child→host `evt` topics all
|
||||
// have explicit cases above. Anything else is a compromised/buggy child
|
||||
// (or, historically, code that reached the raw channel) — drop it rather
|
||||
// than forward it blindly. `onEvent` stays on SupervisorHooks, reserved
|
||||
// for a future SDK-sanctioned custom-event channel, but is not invoked
|
||||
// for arbitrary topics.
|
||||
this.hooks.onLog?.(sup.id, 'warn', `dropped unknown plugin event topic: ${msg.topic}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* GET /api/trip-warnings/:tripId — validation/warning contributions from plugins
|
||||
* that implement the `warningProvider` hook (#1429). Additive and fail-safe: the
|
||||
* caller must be able to access the trip, each provider is called host→plugin with
|
||||
* a short timeout, and a provider that errors or times out contributes nothing.
|
||||
*/
|
||||
type Level = 'info' | 'warning' | 'error';
|
||||
interface Warning {
|
||||
pluginId: string;
|
||||
level: Level;
|
||||
message: string;
|
||||
dayId?: number;
|
||||
placeId?: number;
|
||||
}
|
||||
|
||||
@Controller('api/trip-warnings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TripWarningsController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get(':tripId')
|
||||
async get(
|
||||
@Param('tripId') tripIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ warnings: Warning[] }> {
|
||||
if (!pluginsEnabled()) return { warnings: [] };
|
||||
const tripId = Number(tripIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(tripId) || userId == null || !canAccessTrip(tripId, userId)) return { warnings: [] };
|
||||
|
||||
const ids = this.runtime.providersOf('warningProvider');
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<Warning[]> => {
|
||||
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) => ({
|
||||
pluginId: id,
|
||||
level: w.level === 'error' || w.level === 'info' ? (w.level as Level) : 'warning',
|
||||
message: String(w.message ?? ''),
|
||||
dayId: typeof w.dayId === 'number' ? w.dayId : undefined,
|
||||
placeId: typeof w.placeId === 'number' ? w.placeId : undefined,
|
||||
}));
|
||||
} catch {
|
||||
return []; // a slow / failing provider contributes nothing
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { warnings: perProvider.flat().filter((w) => w.message) };
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,9 @@ export class TripsController {
|
||||
}
|
||||
|
||||
@Get('cover-images/search')
|
||||
async coverImages(@Query('query') query?: string) {
|
||||
async coverImages(@CurrentUser() user: User, @Query('query') query?: string) {
|
||||
try {
|
||||
const result = await this.trips.searchCoverImages(query || '');
|
||||
const result = await this.trips.searchCoverImages(query || '', user.id);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { listItems as listTodoItems } from '../../services/todoService';
|
||||
import { listBudgetItems } from '../../services/budgetService';
|
||||
import { listReservations } from '../../services/reservationService';
|
||||
import { listFiles } from '../../services/fileService';
|
||||
import { searchUnsplashPhotos } from '../../services/unsplashService';
|
||||
import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing trip service + the per-domain list
|
||||
@@ -50,8 +50,8 @@ export class TripsService {
|
||||
return tripSvc.getTripRaw(tripId);
|
||||
}
|
||||
|
||||
searchCoverImages(query: string) {
|
||||
return searchUnsplashPhotos(query, 9);
|
||||
searchCoverImages(query: string, userId: number) {
|
||||
return searchUnsplashPhotos(query, 9, getUnsplashKey(userId));
|
||||
}
|
||||
|
||||
getOwner(tripId: string) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// A tiny, dependency-free relay so the plugin runtime can receive core trip events
|
||||
// without the websocket module (which pulls in `ws`) importing the plugins layer —
|
||||
// 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 {
|
||||
sink = fn;
|
||||
}
|
||||
|
||||
export function emitPluginEvent(tripId: number, event: string): void {
|
||||
if (!sink) return;
|
||||
try {
|
||||
sink(tripId, event);
|
||||
} catch {
|
||||
/* a plugin sink must never break a core broadcast */
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
`).all(a.place_id);
|
||||
|
||||
const participants = db.prepare(`
|
||||
SELECT ap.user_id, u.username, u.avatar
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
@@ -159,7 +159,7 @@ export function moveAssignment(id: string | number, newDayId: string | number, o
|
||||
|
||||
export function getParticipants(assignmentId: string | number) {
|
||||
return db.prepare(`
|
||||
SELECT ap.user_id, u.username, u.avatar
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
@@ -208,7 +208,7 @@ export function setParticipants(assignmentId: string | number, userIds: number[]
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
SELECT ap.user_id, u.username, u.avatar
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
|
||||
@@ -622,34 +622,35 @@ export function updateMapsKey(userId: number, maps_api_key: string | null | unde
|
||||
|
||||
export function updateApiKeys(
|
||||
userId: number,
|
||||
body: { maps_api_key?: string; openweather_api_key?: string }
|
||||
body: { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string }
|
||||
) {
|
||||
const current = db.prepare('SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick<User, 'maps_api_key' | 'openweather_api_key'> | undefined;
|
||||
const current = db.prepare('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?').get(userId) as Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(
|
||||
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current!.maps_api_key,
|
||||
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key,
|
||||
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current!.unsplash_api_key,
|
||||
userId
|
||||
);
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
return {
|
||||
success: true,
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSettings(
|
||||
userId: number,
|
||||
body: { maps_api_key?: string; openweather_api_key?: string; username?: string; email?: string }
|
||||
body: { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string }
|
||||
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
|
||||
const { maps_api_key, openweather_api_key, username, email } = body;
|
||||
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
|
||||
|
||||
if (username !== undefined) {
|
||||
const trimmed = username.trim();
|
||||
@@ -677,6 +678,7 @@ export function updateSettings(
|
||||
|
||||
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
|
||||
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
|
||||
if (unsplash_api_key !== undefined) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); }
|
||||
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
|
||||
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
|
||||
|
||||
@@ -687,26 +689,27 @@ export function updateSettings(
|
||||
}
|
||||
|
||||
const updated = db.prepare(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
|
||||
|
||||
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
|
||||
return {
|
||||
success: true,
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export function getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } {
|
||||
const user = db.prepare(
|
||||
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?'
|
||||
).get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?'
|
||||
).get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined;
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
|
||||
|
||||
return {
|
||||
settings: {
|
||||
maps_api_key: decrypt_api_key(user.maps_api_key),
|
||||
openweather_api_key: decrypt_api_key(user.openweather_api_key),
|
||||
unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
|
||||
import { avatarUrl } from './avatarUrl';
|
||||
import { getRates } from './exchangeRateService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -11,7 +12,7 @@ export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
function loadItemMembers(itemId: number | string) {
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, bm.amount, u.username, u.avatar
|
||||
SELECT bm.user_id, bm.paid, 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 = ?
|
||||
@@ -21,7 +22,7 @@ function loadItemMembers(itemId: number | string) {
|
||||
|
||||
function loadItemPayers(itemId: number | string) {
|
||||
const rows = db.prepare(`
|
||||
SELECT bp.user_id, bp.amount, u.username, u.avatar
|
||||
SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id = ?
|
||||
@@ -60,7 +61,7 @@ export function listBudgetItems(tripId: string | number) {
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
const allMembers = db.prepare(`
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, u.username, u.avatar
|
||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, 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 (${itemIds.map(() => '?').join(',')})
|
||||
@@ -77,7 +78,7 @@ export function listBudgetItems(tripId: string | number) {
|
||||
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
|
||||
if (itemIds.length > 0) {
|
||||
const allPayers = db.prepare(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||
@@ -98,6 +99,48 @@ export function listBudgetItems(tripId: string | number) {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the live FX rate at entry time into `exchange_rate` so a settled position
|
||||
* isn't re-opened when live rates drift later (#1335 / #1445). The stored rate is
|
||||
* "units of the item/display currency per 1 trip currency" — the settlement
|
||||
* converts with it via `amount / rate`.
|
||||
*
|
||||
* Only freezes for a foreign currency with no explicit rate; degrades to live
|
||||
* rates if the fetch fails. On update it (re)freezes only when the currency
|
||||
* changes (checked against `budget_items`), so an unrelated edit never moves
|
||||
* money. Callers must invoke this *before* the (synchronous) DB write — the raw
|
||||
* create/update stay sync because better-sqlite3 transactions can't await.
|
||||
*/
|
||||
export async function freezeForeignRate(
|
||||
tripId: string | number,
|
||||
data: { currency?: string | null; exchange_rate?: number },
|
||||
existingItemId?: string | number,
|
||||
existingCurrency?: string | null,
|
||||
): Promise<void> {
|
||||
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
|
||||
const cur = (data.currency || '').toUpperCase();
|
||||
if (!cur) return; // currency not being set in this request
|
||||
// Skip the re-freeze when the currency isn't actually changing, so an unrelated
|
||||
// edit never moves money. Items resolve the prior currency from budget_items; a
|
||||
// settlement lives in a different table, so its caller passes it in directly.
|
||||
let prior: string | undefined;
|
||||
if (existingCurrency !== undefined) {
|
||||
prior = (existingCurrency || '').toUpperCase();
|
||||
} else if (existingItemId != null) {
|
||||
const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?')
|
||||
.get(existingItemId) as { currency?: string } | undefined;
|
||||
if (existing) prior = (existing.currency || '').toUpperCase();
|
||||
}
|
||||
if (prior !== undefined && prior === cur) return; // currency unchanged
|
||||
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
|
||||
.get(tripId) as { currency?: string } | undefined;
|
||||
const tripCur = (trip?.currency || 'EUR').toUpperCase();
|
||||
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
|
||||
const rates = await getRates(tripCur);
|
||||
const r = rates?.[cur];
|
||||
if (r && r > 0) data.exchange_rate = r;
|
||||
}
|
||||
|
||||
export function createBudgetItem(
|
||||
tripId: string | number,
|
||||
data: {
|
||||
@@ -318,7 +361,7 @@ export function toggleMemberPaid(id: string | number, tripId: string | number, u
|
||||
.run(paid ? 1 : 0, id, userId);
|
||||
|
||||
const member = db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, u.username, u.avatar
|
||||
SELECT bm.user_id, bm.paid, 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 = ? AND bm.user_id = ?
|
||||
`).get(id, userId) as BudgetItemMember | undefined;
|
||||
@@ -332,7 +375,7 @@ export function toggleMemberPaid(id: string | number, tripId: string | number, u
|
||||
|
||||
export function getPerPersonSummary(tripId: string | number) {
|
||||
const summary = db.prepare(`
|
||||
SELECT bm.user_id, u.username, u.avatar,
|
||||
SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
|
||||
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||
COUNT(bi.id) as items_count
|
||||
@@ -398,20 +441,37 @@ export function calculateSettlement(
|
||||
// trip-currency → display currency, applied once to the final netted totals.
|
||||
const toDisplay = (v: number): number =>
|
||||
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer was
|
||||
// viewing (the table has no currency column), so bring it into trip currency to net.
|
||||
const settleToTrip = (v: number): number =>
|
||||
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v * rates[tripCurrency] : v);
|
||||
// A recorded settle-up amount is entered in whatever display currency the payer
|
||||
// was viewing. New rows capture that currency and the rate frozen at settle time
|
||||
// (#1445), so a settled position stays balanced when live rates drift — mirroring
|
||||
// toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall
|
||||
// back to the old behaviour: assume they were entered in the current display base
|
||||
// and convert with live rates.
|
||||
const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => {
|
||||
if (sCurrency) {
|
||||
const cur = sCurrency.toUpperCase();
|
||||
if (cur === tripCurrency) return amount;
|
||||
if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate;
|
||||
// Frozen currency but no usable rate (fetch failed at settle time): live fallback.
|
||||
if (rates) {
|
||||
const rCur = rates[cur];
|
||||
const rTrip = rates[tripCurrency];
|
||||
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
|
||||
};
|
||||
|
||||
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, u.username, u.avatar
|
||||
SELECT bm.budget_item_id, bm.user_id, 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 = ?)
|
||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||
const allPayers = db.prepare(`
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
||||
SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM budget_item_payers bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||
@@ -457,8 +517,9 @@ export function calculateSettlement(
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += settleToTrip(s.amount);
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= settleToTrip(s.amount);
|
||||
const inTrip = settleToTrip(s.amount, s.currency, s.exchange_rate);
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += inTrip;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= inTrip;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
@@ -501,7 +562,7 @@ export function calculateSettlement(
|
||||
|
||||
export function listSettlements(tripId: string | number) {
|
||||
const rows = db.prepare(`
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id,
|
||||
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.created_by_user_id,
|
||||
fu.username AS from_username, fu.avatar AS from_avatar,
|
||||
tu.username AS to_username, tu.avatar AS to_avatar
|
||||
FROM budget_settlements s
|
||||
@@ -513,7 +574,8 @@ export function listSettlements(tripId: string | number) {
|
||||
return rows.map(r => ({
|
||||
id: r.id, trip_id: r.trip_id,
|
||||
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
||||
amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||
amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1,
|
||||
created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
||||
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
||||
}));
|
||||
@@ -521,25 +583,39 @@ export function listSettlements(tripId: string | number) {
|
||||
|
||||
export function createSettlement(
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
createdByUserId?: number,
|
||||
) {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null);
|
||||
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||
createdByUserId ?? null,
|
||||
);
|
||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||
}
|
||||
|
||||
export function updateSettlement(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
|
||||
) {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return null;
|
||||
db.prepare(
|
||||
'UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?'
|
||||
).run(data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, id);
|
||||
db.prepare(`
|
||||
UPDATE budget_settlements SET
|
||||
from_user_id = ?, to_user_id = ?, amount = ?,
|
||||
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
|
||||
data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null,
|
||||
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
|
||||
id,
|
||||
);
|
||||
return listSettlements(tripId).find(s => s.id === Number(id)) || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TripFile } from '../types';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass,md,markdown';
|
||||
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass,pkpasses,md,markdown';
|
||||
|
||||
// Video support (#823). Gallery/media uploads accept these in addition to images,
|
||||
// independent of the admin doc-types allowlist. Videos are stored as-is and
|
||||
@@ -164,7 +164,7 @@ export function listFiles(tripId: string | number, showTrash: boolean) {
|
||||
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
|
||||
|
||||
const fileIds = files.map(f => f.id);
|
||||
let linksMap: Record<number, FileLink[]> = {};
|
||||
const linksMap: Record<number, FileLink[]> = {};
|
||||
if (fileIds.length > 0) {
|
||||
const placeholders = fileIds.map(() => '?').join(',');
|
||||
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as FileLink[];
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function sendPasswordResetEmail(
|
||||
// No SMTP configured — log the link in a visually distinct block so
|
||||
// the admin can relay it. Never log the associated user id/email
|
||||
// content at a lower level, only what's needed.
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log(
|
||||
`\n===== PASSWORD RESET LINK =====\n` +
|
||||
`to: ${to}\n` +
|
||||
|
||||
@@ -22,7 +22,7 @@ function enrichItems(items: any[]): any[] {
|
||||
const ownerName = new Map(owners.map(o => [o.id, o.username]));
|
||||
|
||||
const recipientRows = db.prepare(`
|
||||
SELECT r.item_id, r.user_id, u.username
|
||||
SELECT r.item_id, r.user_id, COALESCE(u.display_name, u.username) AS username
|
||||
FROM packing_item_recipients r JOIN users u ON u.id = r.user_id
|
||||
WHERE r.item_id IN (${placeholders})
|
||||
`).all(...ids) as { item_id: number; user_id: number; username: string }[];
|
||||
@@ -33,7 +33,7 @@ function enrichItems(items: any[]): any[] {
|
||||
}
|
||||
|
||||
const contributorRows = db.prepare(`
|
||||
SELECT c.item_id, c.user_id, c.status, u.username
|
||||
SELECT c.item_id, c.user_id, c.status, COALESCE(u.display_name, u.username) AS username
|
||||
FROM packing_item_contributors c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.item_id IN (${placeholders})
|
||||
`).all(...ids) as { item_id: number; user_id: number; status: string; username: string }[];
|
||||
@@ -290,7 +290,7 @@ export function bulkImport(tripId: string | number, items: ImportItem[], ownerId
|
||||
export function listBags(tripId: string | number) {
|
||||
const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId) as any[];
|
||||
const members = db.prepare(`
|
||||
SELECT bm.bag_id, bm.user_id, u.username, u.avatar
|
||||
SELECT bm.bag_id, 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
|
||||
JOIN packing_bags b ON bm.bag_id = b.id
|
||||
@@ -314,7 +314,7 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
|
||||
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);
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, u.username, u.avatar
|
||||
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
|
||||
WHERE bm.bag_id = ?
|
||||
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
|
||||
@@ -351,7 +351,7 @@ export function updateBag(
|
||||
data.user_id ?? null,
|
||||
bagId
|
||||
);
|
||||
return db.prepare('SELECT b.*, u.username as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?').get(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);
|
||||
}
|
||||
|
||||
export function deleteBag(tripId: string | number, bagId: string | number) {
|
||||
@@ -440,7 +440,7 @@ export function saveAsTemplate(tripId: string | number, userId: number, template
|
||||
|
||||
export function getCategoryAssignees(tripId: string | number) {
|
||||
const rows = db.prepare(`
|
||||
SELECT pca.category_name, pca.user_id, u.username, u.avatar
|
||||
SELECT pca.category_name, pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM packing_category_assignees pca
|
||||
JOIN users u ON pca.user_id = u.id
|
||||
WHERE pca.trip_id = ?
|
||||
@@ -465,7 +465,7 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
|
||||
}
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT pca.user_id, u.username, u.avatar
|
||||
SELECT pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
FROM packing_category_assignees pca
|
||||
JOIN users u ON pca.user_id = u.id
|
||||
WHERE pca.trip_id = ? AND pca.category_name = ?
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from './kmlImport';
|
||||
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
import { searchUnsplashPhotos } from './unsplashService';
|
||||
import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService';
|
||||
import { type UpdateConflict, isUpdateConflict } from './conflictResult';
|
||||
|
||||
// Reclaim a deleted place's cached marker photo if nothing else references it.
|
||||
@@ -977,9 +977,9 @@ export async function importNaverList(
|
||||
// Search place image (Unsplash)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPlaceImage(tripId: string, placeId: string, _userId: number) {
|
||||
export async function searchPlaceImage(tripId: string, placeId: string, userId: number) {
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
|
||||
if (!place) return { error: 'Place not found', status: 404 };
|
||||
|
||||
return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5);
|
||||
return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId));
|
||||
}
|
||||
|
||||
@@ -352,10 +352,10 @@ export function listMembers(tripId: string | number, tripOwnerId: number) {
|
||||
// u.is_guest rides along (#1362) so guests stay assignable everywhere a member is,
|
||||
// while the UI can badge them and suppress owner-only actions. The owner is never a guest.
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.username, u.email, u.avatar, u.is_guest,
|
||||
SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, u.avatar, u.is_guest,
|
||||
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
|
||||
m.added_at,
|
||||
ib.username as invited_by_username
|
||||
COALESCE(ib.display_name, ib.username) as invited_by_username
|
||||
FROM trip_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
LEFT JOIN users ib ON ib.id = m.invited_by
|
||||
@@ -473,37 +473,28 @@ export interface GuestMember {
|
||||
|
||||
/** username is UNIQUE across all users — keep the typed name but disambiguate guests
|
||||
* that happen to share it (e.g. two "Anna"s) with a numeric suffix. */
|
||||
function uniqueGuestUsername(name: string, excludeId?: number): string {
|
||||
let candidate = name;
|
||||
let n = 2;
|
||||
const probe = excludeId != null
|
||||
? db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?')
|
||||
: db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)');
|
||||
while (excludeId != null ? probe.get(candidate, excludeId) : probe.get(candidate)) {
|
||||
candidate = `${name} ${n++}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } {
|
||||
const display = (name || '').trim();
|
||||
if (!display) throw new ValidationError('Guest name is required');
|
||||
if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer');
|
||||
|
||||
// The human name lives in display_name (not unique — two trips can each have a
|
||||
// "Jake", #1446); username is a uuid handle only for the UNIQUE constraint and is
|
||||
// never shown (member views COALESCE display_name over it).
|
||||
const email = `guest-${randomUUID()}@guests.invalid`;
|
||||
const username = uniqueGuestUsername(display);
|
||||
const username = `guest-${randomUUID()}`;
|
||||
|
||||
const create = db.transaction(() => {
|
||||
const res = db.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role, is_guest) VALUES (?, ?, '', 'user', 1)"
|
||||
).run(username, email);
|
||||
"INSERT INTO users (username, email, password_hash, role, is_guest, display_name) VALUES (?, ?, '', 'user', 1, ?)"
|
||||
).run(username, email, display);
|
||||
const guestId = Number(res.lastInsertRowid);
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId);
|
||||
return guestId;
|
||||
});
|
||||
const guestId = create();
|
||||
|
||||
return { member: { id: guestId, username, email, role: 'member', is_guest: true, avatar_url: null } };
|
||||
return { member: { id: guestId, username: display, email, role: 'member', is_guest: true, avatar_url: null } };
|
||||
}
|
||||
|
||||
/** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */
|
||||
@@ -519,8 +510,9 @@ export function renameGuest(tripId: string | number, guestUserId: number, name:
|
||||
if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer');
|
||||
if (!guestOfTrip(tripId, guestUserId)) return false;
|
||||
|
||||
const username = uniqueGuestUsername(display, guestUserId);
|
||||
db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(username, guestUserId);
|
||||
// Rename only the display name — no global-uniqueness dedup, so a rename to a name
|
||||
// another trip's guest already uses no longer produces "Name 2" (#1446).
|
||||
db.prepare('UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(display, guestUserId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -665,7 +657,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
if (notes.length > 0) {
|
||||
if (desc) desc += '\n\n';
|
||||
desc += 'Notes:\n' + notes.map(n => {
|
||||
let line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`;
|
||||
const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`;
|
||||
return line;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { safeFetch } from '../utils/ssrfGuard';
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
interface UnsplashSearchResponse {
|
||||
results?: {
|
||||
@@ -25,7 +27,24 @@ export interface UnsplashPhoto {
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export async function searchUnsplashPhotos(query: string, perPage = 9) {
|
||||
/**
|
||||
* Resolve an Unsplash access key for a request, in precedence order:
|
||||
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
|
||||
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
|
||||
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
|
||||
* in which case the search falls back to the unauthenticated endpoint.
|
||||
*/
|
||||
export function getUnsplashKey(userId: number): string | null {
|
||||
const env_key = process.env.UNSPLASH_ACCESS_KEY?.trim();
|
||||
if (env_key) return env_key;
|
||||
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined;
|
||||
const user_key = decrypt_api_key(user?.unsplash_api_key);
|
||||
if (user_key) return user_key;
|
||||
const admin = db.prepare("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1").get() as { unsplash_api_key: string } | undefined;
|
||||
return decrypt_api_key(admin?.unsplash_api_key) || null;
|
||||
}
|
||||
|
||||
export async function searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return { error: 'Search query is required', status: 400 };
|
||||
@@ -36,17 +55,27 @@ export async function searchUnsplashPhotos(query: string, perPage = 9) {
|
||||
query: trimmed,
|
||||
per_page: String(perPage),
|
||||
});
|
||||
const response = await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
|
||||
Accept: '*/*',
|
||||
'Accept-Language': 'en-US',
|
||||
Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`,
|
||||
'client-geo-region': 'global',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
},
|
||||
});
|
||||
// With an access key, use Unsplash's official, authenticated API — datacenter
|
||||
// (VPS) IPs get blocked on the unauthenticated web endpoint (#1449). Without
|
||||
// one, fall back to the web endpoint so zero-config search still works.
|
||||
const response = accessKey
|
||||
? await fetch(`https://api.unsplash.com/search/photos?${params.toString()}`, {
|
||||
headers: {
|
||||
Authorization: `Client-ID ${accessKey}`,
|
||||
'Accept-Version': 'v1',
|
||||
},
|
||||
})
|
||||
: await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
|
||||
Accept: '*/*',
|
||||
'Accept-Language': 'en-US',
|
||||
Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`,
|
||||
'client-geo-region': 'global',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
},
|
||||
});
|
||||
let data: UnsplashSearchResponse;
|
||||
try {
|
||||
data = await response.json() as UnsplashSearchResponse;
|
||||
|
||||
@@ -1,6 +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 { User } from './types';
|
||||
import http from 'node:http';
|
||||
|
||||
@@ -192,6 +193,10 @@ function leaveRoom(ws: NomadWebSocket, tripId: number): void {
|
||||
*/
|
||||
function broadcast(tripId: number | string, eventType: string, payload: Record<string, unknown>, excludeSid?: number | string, onlyUserId?: number): void {
|
||||
tripId = Number(tripId);
|
||||
// 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);
|
||||
const room = rooms.get(tripId);
|
||||
if (!room || room.size === 0) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user