From 91025683bb9c3fc112e7180fd6e2e4774d84bf3f Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:27:42 +0200 Subject: [PATCH] 3.2.1 (#1433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `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 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 , 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 `` 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 `` 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 `` 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 / … / ), 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 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:, 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 Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com> --- CODE_OF_CONDUCT.md | 136 ++ README.md | 1 + charts/trek/templates/deployment.yaml | 6 + charts/trek/templates/secret.yaml | 6 + charts/trek/values.yaml | 6 + client/src/api/client.ts | 41 +- .../components/Admin/AdminPluginsPanel.tsx | 298 ++- .../src/components/Budget/CostsPanel.test.tsx | 23 + client/src/components/Budget/CostsPanel.tsx | 57 +- .../Files/FileManager.helpers.test.ts | 23 + .../components/Files/FileManager.helpers.ts | 12 + .../src/components/Files/FileManager.test.tsx | 24 + client/src/components/Files/useFileManager.ts | 7 +- .../Planner/DayPlanSidebar.test.tsx | 1 + .../src/components/Planner/DayPlanSidebar.tsx | 30 +- .../Planner/PlaceInspector.test.tsx | 4 +- .../src/components/Planner/PlaceInspector.tsx | 49 +- .../src/components/Planner/PlacesSidebar.tsx | 10 +- .../components/Planner/PlacesSidebarRow.tsx | 5 +- .../Planner/ReservationModal.test.tsx | 6 + .../components/Planner/ReservationModal.tsx | 2 +- .../Planner/TransportModal.test.tsx | 6 + .../src/components/Planner/TransportModal.tsx | 2 +- .../components/Planner/TripWarningsBanner.tsx | 46 + .../components/Plugins/PluginFrame.test.tsx | 39 +- client/src/components/Plugins/PluginFrame.tsx | 153 +- .../src/components/Plugins/PluginWidgets.tsx | 35 +- client/src/components/shared/Modal.tsx | 2 +- client/src/pages/AdminPage.test.tsx | 32 +- client/src/pages/DashboardPage.test.tsx | 33 + client/src/pages/DashboardPage.tsx | 27 +- client/src/pages/TripPlannerPage.tsx | 14 +- client/src/pages/admin/AdminSettingsTab.tsx | 26 +- client/src/pages/admin/useAdmin.ts | 9 +- .../src/pages/tripPlanner/useTripPlanner.ts | 14 +- client/src/store/pluginStore.test.ts | 4 +- client/src/store/pluginStore.ts | 10 +- client/vite.config.js | 25 +- docker-compose.yml | 1 + plugin-sdk/README.md | 40 +- plugin-sdk/examples/trip-doctor/README.md | 41 + .../examples/trip-doctor/server/index.js | 73 + .../examples/trip-doctor/trek-plugin.json | 23 + plugin-sdk/package-lock.json | 1687 ++++++++--------- plugin-sdk/package.json | 71 +- plugin-sdk/src/cli/create.ts | 253 ++- plugin-sdk/src/cli/dev.ts | 111 +- plugin-sdk/src/cli/menu.ts | 70 + plugin-sdk/src/cli/pack.ts | 10 +- plugin-sdk/src/cli/publish.ts | 18 +- plugin-sdk/src/cli/trek-plugin.ts | 269 ++- plugin-sdk/src/cli/ui.ts | 122 ++ plugin-sdk/src/cli/validate.ts | 7 +- plugin-sdk/src/index.ts | 131 +- plugin-sdk/src/manifest.ts | 99 +- plugin-sdk/src/mock-host.ts | 255 ++- plugin-sdk/src/ui/kit.ts | 335 ++++ plugin-sdk/test/cli.test.ts | 122 ++ plugin-sdk/test/sdk.test.ts | 137 ++ plugin-sdk/test/ui-kit.test.ts | 56 + server/.env.example | 2 + server/src/db/migrations.ts | 64 + server/src/mcp/tools/budget.ts | 8 +- server/src/nest/admin/admin.controller.ts | 14 +- server/src/nest/admin/admin.module.ts | 2 + .../booking-import/booking-import.service.ts | 10 +- server/src/nest/budget/budget.controller.ts | 15 +- server/src/nest/budget/budget.service.ts | 42 +- .../nest/files/files-download.controller.ts | 14 +- server/src/nest/plugins/dependencies.ts | 150 ++ .../src/nest/plugins/host/create-rpc-host.ts | 222 ++- server/src/nest/plugins/host/plugin-audit.ts | 22 +- server/src/nest/plugins/host/rpc-host.ts | 317 +++- server/src/nest/plugins/install/discovery.ts | 11 +- server/src/nest/plugins/install/manifest.ts | 86 +- .../nest/plugins/place-details.controller.ts | 52 + .../nest/plugins/plugin-runtime.service.ts | 290 ++- .../nest/plugins/plugins-feed.controller.ts | 7 +- .../nest/plugins/plugins-proxy.controller.ts | 34 +- server/src/nest/plugins/plugins.controller.ts | 44 +- server/src/nest/plugins/plugins.module.ts | 7 +- server/src/nest/plugins/plugins.service.ts | 54 +- server/src/nest/plugins/protocol/envelope.ts | 52 + .../nest/plugins/registry/registry.service.ts | 171 +- .../nest/plugins/runtime/plugin-host-entry.ts | 147 +- server/src/nest/plugins/runtime/plugin-sdk.ts | 154 +- .../plugins/supervisor/plugin-supervisor.ts | 87 +- .../nest/plugins/trip-warnings.controller.ts | 58 + server/src/nest/trips/trips.controller.ts | 4 +- server/src/nest/trips/trips.service.ts | 6 +- server/src/plugin-event-sink.ts | 19 + server/src/services/assignmentService.ts | 6 +- server/src/services/authService.ts | 29 +- server/src/services/budgetService.ts | 122 +- server/src/services/fileService.ts | 4 +- server/src/services/notifications.ts | 2 +- server/src/services/packingService.ts | 14 +- server/src/services/placeService.ts | 6 +- server/src/services/tripService.ts | 34 +- server/src/services/unsplashService.ts | 53 +- server/src/websocket.ts | 5 + server/tests/e2e/budget.e2e.test.ts | 4 +- server/tests/e2e/collections.e2e.test.ts | 16 + .../plugins/plugin-runtime.test.ts | 138 +- .../integration/plugins/registry.test.ts | 89 +- .../integration/plugins/supervisor.test.ts | 129 +- server/tests/integration/trips.test.ts | 25 +- .../tests/unit/nest/admin.controller.test.ts | 19 +- .../tests/unit/nest/budget.controller.test.ts | 42 +- server/tests/unit/nest/budget.service.test.ts | 15 +- .../tests/unit/nest/files.controller.test.ts | 18 + .../unit/plugins/create-rpc-host.test.ts | 185 +- .../tests/unit/plugins/dependencies.test.ts | 107 ++ server/tests/unit/plugins/discovery.test.ts | 2 +- .../unit/plugins/event-subscriptions.test.ts | 52 + server/tests/unit/plugins/manifest.test.ts | 41 + .../plugins/place-details.controller.test.ts | 61 + .../tests/unit/plugins/plugin-audit.test.ts | 7 + server/tests/unit/plugins/plugin-sdk.test.ts | 17 +- .../tests/unit/plugins/plugins-proxy.test.ts | 42 + .../unit/plugins/plugins-service.test.ts | 8 +- .../unit/plugins/provider-hook-grant.test.ts | 51 + server/tests/unit/plugins/rpc-host.test.ts | 331 ++++ .../plugins/trip-warnings.controller.test.ts | 57 + .../tests/unit/services/authServiceDb.test.ts | 10 + .../tests/unit/services/budgetService.test.ts | 135 +- .../tests/unit/services/tripService.test.ts | 10 +- .../unit/services/unsplashService.test.ts | 62 +- .../src/appearance/appearance.schema.spec.ts | 3 +- shared/src/budget/budget.schema.ts | 12 +- .../src/collection/collection.schema.spec.ts | 22 + shared/src/collection/collection.schema.ts | 19 +- shared/src/i18n/ar/admin.ts | 82 +- shared/src/i18n/ar/budget.ts | 11 +- shared/src/i18n/ar/collection.ts | 6 +- shared/src/i18n/ar/help.ts | 10 +- shared/src/i18n/ar/index.ts | 4 +- shared/src/i18n/ar/login.ts | 5 +- shared/src/i18n/ar/places.ts | 4 +- shared/src/i18n/ar/settings.ts | 24 +- shared/src/i18n/ar/trip.ts | 3 +- shared/src/i18n/ar/undo.ts | 2 +- shared/src/i18n/br/admin.ts | 59 +- shared/src/i18n/br/budget.ts | 11 +- shared/src/i18n/br/collection.ts | 6 +- shared/src/i18n/br/help.ts | 10 +- shared/src/i18n/br/index.ts | 4 +- shared/src/i18n/br/login.ts | 5 +- shared/src/i18n/br/places.ts | 4 +- shared/src/i18n/br/settings.ts | 36 +- shared/src/i18n/br/trip.ts | 3 +- shared/src/i18n/br/undo.ts | 2 +- shared/src/i18n/cs/admin.ts | 61 +- shared/src/i18n/cs/budget.ts | 11 +- shared/src/i18n/cs/help.ts | 10 +- shared/src/i18n/cs/index.ts | 4 +- shared/src/i18n/cs/login.ts | 5 +- shared/src/i18n/cs/places.ts | 4 +- shared/src/i18n/cs/settings.ts | 27 +- shared/src/i18n/cs/trip.ts | 3 +- shared/src/i18n/cs/undo.ts | 2 +- shared/src/i18n/de/admin.ts | 93 +- shared/src/i18n/de/budget.ts | 11 +- shared/src/i18n/de/collection.ts | 9 +- shared/src/i18n/de/help.ts | 3 +- shared/src/i18n/de/index.ts | 4 +- shared/src/i18n/de/login.ts | 7 +- shared/src/i18n/de/members.ts | 3 +- shared/src/i18n/de/places.ts | 4 +- shared/src/i18n/de/settings.ts | 30 +- shared/src/i18n/de/trip.ts | 3 +- shared/src/i18n/de/undo.ts | 2 +- shared/src/i18n/en/admin.ts | 80 +- shared/src/i18n/en/budget.ts | 3 + shared/src/i18n/en/collection.ts | 3 +- shared/src/i18n/en/help.ts | 3 +- shared/src/i18n/en/index.ts | 4 +- shared/src/i18n/en/login.ts | 5 +- shared/src/i18n/en/members.ts | 3 +- shared/src/i18n/en/places.ts | 4 +- shared/src/i18n/en/settings.ts | 18 +- shared/src/i18n/en/trip.ts | 3 +- shared/src/i18n/en/undo.ts | 2 +- shared/src/i18n/es/admin.ts | 67 +- shared/src/i18n/es/budget.ts | 11 +- shared/src/i18n/es/collection.ts | 6 +- shared/src/i18n/es/help.ts | 10 +- shared/src/i18n/es/index.ts | 4 +- shared/src/i18n/es/login.ts | 5 +- shared/src/i18n/es/members.ts | 3 +- shared/src/i18n/es/places.ts | 4 +- shared/src/i18n/es/settings.ts | 33 +- shared/src/i18n/es/trip.ts | 3 +- shared/src/i18n/es/undo.ts | 2 +- shared/src/i18n/fr/admin.ts | 65 +- shared/src/i18n/fr/budget.ts | 11 +- shared/src/i18n/fr/collection.ts | 8 +- shared/src/i18n/fr/dashboard.ts | 3 +- shared/src/i18n/fr/help.ts | 10 +- shared/src/i18n/fr/index.ts | 4 +- shared/src/i18n/fr/login.ts | 5 +- shared/src/i18n/fr/members.ts | 9 +- shared/src/i18n/fr/packing.ts | 6 +- shared/src/i18n/fr/places.ts | 4 +- shared/src/i18n/fr/settings.ts | 42 +- shared/src/i18n/fr/trip.ts | 22 +- shared/src/i18n/fr/undo.ts | 2 +- shared/src/i18n/gr/admin.ts | 64 +- shared/src/i18n/gr/budget.ts | 11 +- shared/src/i18n/gr/collection.ts | 6 +- shared/src/i18n/gr/help.ts | 10 +- shared/src/i18n/gr/index.ts | 4 +- shared/src/i18n/gr/login.ts | 5 +- shared/src/i18n/gr/members.ts | 6 +- shared/src/i18n/gr/places.ts | 4 +- shared/src/i18n/gr/settings.ts | 36 +- shared/src/i18n/gr/trip.ts | 9 +- shared/src/i18n/gr/undo.ts | 2 +- shared/src/i18n/hu/admin.ts | 67 +- shared/src/i18n/hu/budget.ts | 11 +- shared/src/i18n/hu/collection.ts | 9 +- shared/src/i18n/hu/dashboard.ts | 3 +- shared/src/i18n/hu/help.ts | 10 +- shared/src/i18n/hu/index.ts | 4 +- shared/src/i18n/hu/login.ts | 5 +- shared/src/i18n/hu/members.ts | 3 +- shared/src/i18n/hu/places.ts | 4 +- shared/src/i18n/hu/settings.ts | 33 +- shared/src/i18n/hu/trip.ts | 9 +- shared/src/i18n/hu/undo.ts | 2 +- shared/src/i18n/id/admin.ts | 90 +- shared/src/i18n/id/budget.ts | 11 +- shared/src/i18n/id/collection.ts | 3 +- shared/src/i18n/id/help.ts | 10 +- shared/src/i18n/id/index.ts | 4 +- shared/src/i18n/id/login.ts | 5 +- shared/src/i18n/id/members.ts | 3 +- shared/src/i18n/id/places.ts | 4 +- shared/src/i18n/id/settings.ts | 33 +- shared/src/i18n/id/trip.ts | 6 +- shared/src/i18n/id/undo.ts | 2 +- shared/src/i18n/it/admin.ts | 65 +- shared/src/i18n/it/budget.ts | 11 +- shared/src/i18n/it/collection.ts | 14 +- shared/src/i18n/it/dashboard.ts | 3 +- shared/src/i18n/it/help.ts | 10 +- shared/src/i18n/it/index.ts | 4 +- shared/src/i18n/it/login.ts | 5 +- shared/src/i18n/it/members.ts | 7 +- shared/src/i18n/it/packing.ts | 2 +- shared/src/i18n/it/places.ts | 4 +- shared/src/i18n/it/settings.ts | 36 +- shared/src/i18n/it/trip.ts | 3 +- shared/src/i18n/it/undo.ts | 2 +- shared/src/i18n/ja/admin.ts | 56 +- shared/src/i18n/ja/budget.ts | 11 +- shared/src/i18n/ja/dashboard.ts | 3 +- shared/src/i18n/ja/help.ts | 10 +- shared/src/i18n/ja/index.ts | 4 +- shared/src/i18n/ja/login.ts | 5 +- shared/src/i18n/ja/places.ts | 4 +- shared/src/i18n/ja/settings.ts | 24 +- shared/src/i18n/ja/trip.ts | 3 +- shared/src/i18n/ja/undo.ts | 2 +- shared/src/i18n/ko/admin.ts | 54 +- shared/src/i18n/ko/budget.ts | 11 +- shared/src/i18n/ko/dashboard.ts | 3 +- shared/src/i18n/ko/help.ts | 10 +- shared/src/i18n/ko/index.ts | 4 +- shared/src/i18n/ko/login.ts | 5 +- shared/src/i18n/ko/places.ts | 4 +- shared/src/i18n/ko/settings.ts | 27 +- shared/src/i18n/ko/trip.ts | 3 +- shared/src/i18n/ko/undo.ts | 2 +- shared/src/i18n/nl/admin.ts | 66 +- shared/src/i18n/nl/budget.ts | 11 +- shared/src/i18n/nl/collection.ts | 6 +- shared/src/i18n/nl/help.ts | 10 +- shared/src/i18n/nl/index.ts | 4 +- shared/src/i18n/nl/login.ts | 5 +- shared/src/i18n/nl/members.ts | 3 +- shared/src/i18n/nl/places.ts | 4 +- shared/src/i18n/nl/settings.ts | 33 +- shared/src/i18n/nl/system_notice.ts | 2 +- shared/src/i18n/nl/trip.ts | 3 +- shared/src/i18n/nl/undo.ts | 2 +- shared/src/i18n/pl/admin.ts | 70 +- shared/src/i18n/pl/budget.ts | 11 +- shared/src/i18n/pl/collection.ts | 6 +- shared/src/i18n/pl/help.ts | 10 +- shared/src/i18n/pl/index.ts | 4 +- shared/src/i18n/pl/login.ts | 5 +- shared/src/i18n/pl/places.ts | 4 +- shared/src/i18n/pl/settings.ts | 33 +- shared/src/i18n/pl/trip.ts | 3 +- shared/src/i18n/pl/undo.ts | 2 +- shared/src/i18n/ru/admin.ts | 62 +- shared/src/i18n/ru/budget.ts | 11 +- shared/src/i18n/ru/help.ts | 10 +- shared/src/i18n/ru/index.ts | 4 +- shared/src/i18n/ru/login.ts | 5 +- shared/src/i18n/ru/places.ts | 4 +- shared/src/i18n/ru/settings.ts | 33 +- shared/src/i18n/ru/trip.ts | 3 +- shared/src/i18n/ru/undo.ts | 2 +- shared/src/i18n/sv/admin.ts | 98 +- shared/src/i18n/sv/backup.ts | 6 +- shared/src/i18n/sv/budget.ts | 19 +- shared/src/i18n/sv/collab.ts | 2 +- shared/src/i18n/sv/dashboard.ts | 4 +- shared/src/i18n/sv/dayplan.ts | 5 +- shared/src/i18n/sv/externalNotifications.ts | 3 +- shared/src/i18n/sv/help.ts | 10 +- shared/src/i18n/sv/index.ts | 4 +- shared/src/i18n/sv/journey.ts | 4 +- shared/src/i18n/sv/login.ts | 19 +- shared/src/i18n/sv/members.ts | 3 +- shared/src/i18n/sv/memories.ts | 5 +- shared/src/i18n/sv/notifications.ts | 2 +- shared/src/i18n/sv/oauth.ts | 18 +- shared/src/i18n/sv/packing.ts | 3 +- shared/src/i18n/sv/places.ts | 4 +- shared/src/i18n/sv/reservations.ts | 6 +- shared/src/i18n/sv/settings.ts | 44 +- shared/src/i18n/sv/system_notice.ts | 2 +- shared/src/i18n/sv/trip.ts | 3 +- shared/src/i18n/sv/trips.ts | 3 +- shared/src/i18n/sv/undo.ts | 2 +- shared/src/i18n/tr/admin.ts | 62 +- shared/src/i18n/tr/budget.ts | 11 +- shared/src/i18n/tr/collection.ts | 6 +- shared/src/i18n/tr/help.ts | 10 +- shared/src/i18n/tr/index.ts | 4 +- shared/src/i18n/tr/login.ts | 5 +- shared/src/i18n/tr/members.ts | 3 +- shared/src/i18n/tr/places.ts | 4 +- shared/src/i18n/tr/settings.ts | 36 +- shared/src/i18n/tr/trip.ts | 6 +- shared/src/i18n/tr/undo.ts | 2 +- shared/src/i18n/uk/admin.ts | 59 +- shared/src/i18n/uk/budget.ts | 11 +- shared/src/i18n/uk/help.ts | 10 +- shared/src/i18n/uk/index.ts | 4 +- shared/src/i18n/uk/login.ts | 5 +- shared/src/i18n/uk/members.ts | 5 +- shared/src/i18n/uk/places.ts | 4 +- shared/src/i18n/uk/settings.ts | 33 +- shared/src/i18n/uk/trip.ts | 5 +- shared/src/i18n/uk/undo.ts | 2 +- shared/src/i18n/vi/admin.ts | 95 +- shared/src/i18n/vi/backup.ts | 6 +- shared/src/i18n/vi/budget.ts | 3 + shared/src/i18n/vi/collection.ts | 6 +- shared/src/i18n/vi/dashboard.ts | 3 +- shared/src/i18n/vi/externalNotifications.ts | 3 +- shared/src/i18n/vi/help.ts | 3 +- shared/src/i18n/vi/index.ts | 2 +- shared/src/i18n/vi/members.ts | 3 +- shared/src/i18n/vi/memories.ts | 3 +- shared/src/i18n/vi/oauth.ts | 9 +- shared/src/i18n/vi/places.ts | 6 +- shared/src/i18n/vi/reservations.ts | 12 +- shared/src/i18n/vi/settings.ts | 51 +- shared/src/i18n/vi/trip.ts | 3 +- shared/src/i18n/vi/undo.ts | 2 +- shared/src/i18n/vi/vacay.ts | 3 +- shared/src/i18n/zh-TW/admin.ts | 51 +- shared/src/i18n/zh-TW/budget.ts | 11 +- shared/src/i18n/zh-TW/help.ts | 10 +- shared/src/i18n/zh-TW/index.ts | 4 +- shared/src/i18n/zh-TW/login.ts | 5 +- shared/src/i18n/zh-TW/places.ts | 4 +- shared/src/i18n/zh-TW/settings.ts | 9 +- shared/src/i18n/zh-TW/undo.ts | 2 +- shared/src/i18n/zh/admin.ts | 51 +- shared/src/i18n/zh/budget.ts | 11 +- shared/src/i18n/zh/help.ts | 10 +- shared/src/i18n/zh/index.ts | 4 +- shared/src/i18n/zh/login.ts | 5 +- shared/src/i18n/zh/places.ts | 4 +- shared/src/i18n/zh/settings.ts | 9 +- shared/src/i18n/zh/undo.ts | 2 +- .../src/reservation/ki-reservation.schema.ts | 4 +- unraid-template.xml | 1 + wiki/Creating-a-Trip.md | 1 + wiki/Environment-Variables.md | 19 + wiki/Plugin-Cookbook.md | 196 ++ wiki/Plugin-Development.md | 434 ++++- wiki/Plugin-Permissions.md | 24 + wiki/Plugin-Publishing.md | 10 +- wiki/Plugins.md | 60 +- wiki/_Sidebar.md | 1 + 392 files changed, 11059 insertions(+), 2360 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 client/src/components/Files/FileManager.helpers.test.ts create mode 100644 client/src/components/Planner/TripWarningsBanner.tsx create mode 100644 plugin-sdk/examples/trip-doctor/README.md create mode 100644 plugin-sdk/examples/trip-doctor/server/index.js create mode 100644 plugin-sdk/examples/trip-doctor/trek-plugin.json create mode 100644 plugin-sdk/src/cli/menu.ts create mode 100644 plugin-sdk/src/cli/ui.ts create mode 100644 plugin-sdk/src/ui/kit.ts create mode 100644 plugin-sdk/test/cli.test.ts create mode 100644 plugin-sdk/test/ui-kit.test.ts create mode 100644 server/src/nest/plugins/dependencies.ts create mode 100644 server/src/nest/plugins/place-details.controller.ts create mode 100644 server/src/nest/plugins/trip-warnings.controller.ts create mode 100644 server/src/plugin-event-sink.ts create mode 100644 server/tests/unit/plugins/dependencies.test.ts create mode 100644 server/tests/unit/plugins/event-subscriptions.test.ts create mode 100644 server/tests/unit/plugins/place-details.controller.test.ts create mode 100644 server/tests/unit/plugins/provider-hook-grant.test.ts create mode 100644 server/tests/unit/plugins/trip-warnings.controller.test.ts create mode 100644 shared/src/collection/collection.schema.spec.ts create mode 100644 wiki/Plugin-Cookbook.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..5043b6660 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ +# Contributor Covenant 3.0 Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all +individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, +neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or +religion, national or social origin, socio-economic position, level of education, or other status. The same privileges +of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive +behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, +background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared +values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are +violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any + clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of + people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable + identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or + purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their + permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade + enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the + community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other + restricted behaviors. + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict +represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help +avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to +report@liketrek.com**. + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They +will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing +witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as +possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried +out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon +resolution. + +## Addressing and Repairing Harm + +**** + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following +enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals +involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be +skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking + clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a + more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the + seriousness of the situation and give the community members involved time to process the incident. The cooldown + period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and + impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a + single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary + suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for + return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or + a violation so serious that the Community Moderators determine there is no way to keep the community safe with + this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent + bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working + through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their +discretion and judgment, in keeping with the best interests of our community. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing +the community in public or other spaces. Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available +at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy +of this license, +visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ +at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided +at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional +enforcement and community guideline resources can be found +at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement +ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). + diff --git a/README.md b/README.md index 31f8c82d2..92ac2e637 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,7 @@ Caddy handles TLS and WebSockets automatically. | `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random | | **Other** | | | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | +| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | | `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` | diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 09d90dc86..37d52b1c1 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -63,6 +63,12 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: OIDC_CLIENT_SECRET optional: true + - name: UNSPLASH_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: UNSPLASH_ACCESS_KEY + optional: true volumeMounts: - name: data mountPath: /app/data diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index 20edd114f..08d81cabd 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -17,6 +17,9 @@ data: {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} + UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -44,4 +47,7 @@ stringData: {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }} {{- end }} + {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} + UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }} + {{- end }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index e141aa88f..a0523ce14 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -92,6 +92,12 @@ secretEnv: ADMIN_PASSWORD: "" # OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID. OIDC_CLIENT_SECRET: "" + # Optional Unsplash Access Key for trip-cover and place-image search. + # Without one, TREK uses Unsplash's unauthenticated endpoint, which some + # datacenter/VPS IPs (including many Kubernetes clusters) are blocked from. + # Get a free key at https://unsplash.com/developers. Can also be set per-admin + # in Admin > Settings; this value overrides that. Leave empty to disable. + UNSPLASH_ACCESS_KEY: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f9a388d66..fd2165ef1 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -461,14 +461,16 @@ export const adminApi = { addons: () => apiClient.get('/admin/addons').then(r => r.data), updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), plugins: () => apiClient.get('/admin/plugins').then(r => r.data), - pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data), + pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data), pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), - pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data), + pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) => + apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data), pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data), + pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return apiClient.post('/admin/plugins/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data), pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data), // Local LLM (Ollama) management for the AI-parsing addon. @@ -556,9 +558,34 @@ export const addonsApi = { export const pluginsApi = { // Active plugins the client renders (page nav entries, dashboard widgets). active: () => apiClient.get('/plugins').then(r => r.data), - // Call one of a plugin's own declared routes through the host proxy. - invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => - apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data), + // Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe: + // the server skips any slow/failing provider, so this only ever adds rows. + placeDetails: (placeId: number) => + apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }), + // Validation/warning contributions from warningProvider plugins (#1429). Fail-safe. + tripWarnings: (tripId: number) => + apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }), + // Call one of a plugin's own declared routes through the host proxy. `sub` is + // supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it + // MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with + // the URL parser — which normalizes `../`, encoded traversal and backslashes the + // same way the browser would before sending — and reject anything that escapes + // the prefix or points off-origin. Without this a plugin could send + // sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user. + invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => { + const prefix = `/api/plugins/${id}/` + let resolved: URL + try { + resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix) + } catch { + return Promise.reject(new Error('invalid plugin route')) + } + if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) { + return Promise.reject(new Error('plugin route escapes its namespace')) + } + const url = resolved.pathname.slice('/api'.length) + resolved.search + return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data) + }, } export const airtrailApi = { @@ -675,8 +702,8 @@ export const budgetApi = { setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), - createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), - updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index f030adc83..6c021e331 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' import { - Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, + Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, - BookOpen, Wallet, Puzzle, + BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, } from 'lucide-react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' @@ -21,6 +21,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch' * tile; the security section at the bottom explains the model honestly. */ +interface PluginDep { id: string; version: string } +interface VersionMismatch { id: string; wanted: string; installed: string } +type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' +interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] } +interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] } + interface PluginRow { id: string name: string @@ -35,6 +41,9 @@ interface PluginRow { source_repo: string | null permissions: string capabilities: string + dependencies?: PluginDependencies + dependencyStatus?: DependencyStatus + dependencyIssues?: DependencyIssues } interface RegistryItem { id: string @@ -48,6 +57,8 @@ interface RegistryItem { minTrekVersion: string | null reviewedAt: string | null screenshotUrl: string | null + requiredAddons?: string[] + pluginDependencies?: PluginDep[] } interface RegistryDetail extends RegistryItem { size: number | null @@ -58,11 +69,21 @@ interface RegistryDetail extends RegistryItem { settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> license: string | null icon: string | null + requiredAddons?: string[] + pluginDependencies?: PluginDep[] } | null } +/** 409 error-body shape from POST /activate when a dependency blocks activation. */ +interface ActivateErr { + response?: { + status?: number + data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] } + } +} + type T = (k: string, p?: Record) => string -type TypeFilter = 'all' | 'widget' | 'page' | 'integration' +type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page' type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' type SortKey = 'name' | 'recent' | 'updates' @@ -83,11 +104,15 @@ const ICON_MAP: Record v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) @@ -111,16 +136,50 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T) const out: Cap[] = [] if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') }) if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') }) - if (caps.widget) out.push({ icon: LayoutDashboard, label: t(caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : 'admin.plugins.cap.widget') }) + if (perms.includes('db:write:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.writesCosts') }) + else if (perms.includes('db:read:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.readsCosts') }) + if (perms.includes('db:read:packing')) out.push({ icon: Luggage, label: t('admin.plugins.cap.readsPacking') }) + if (perms.includes('db:read:files')) out.push({ icon: FileText, label: t('admin.plugins.cap.readsFiles') }) + if (perms.includes('db:write:places')) out.push({ icon: MapPin, label: t('admin.plugins.cap.writesPlaces') }) + if (perms.includes('db:write:days')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.writesDays') }) + if (perms.includes('db:write:itinerary')) out.push({ icon: ListChecks, label: t('admin.plugins.cap.writesItinerary') }) + if (perms.includes('db:write:trips')) out.push({ icon: Pencil, label: t('admin.plugins.cap.writesTrips') }) + if (perms.includes('db:meta')) out.push({ icon: Tag, label: t('admin.plugins.cap.metadata') }) + if (caps.widget) { + const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' + : caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot' + : 'admin.plugins.cap.widget' + out.push({ icon: LayoutDashboard, label: t(slotKey as never) }) + } if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') }) if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) + if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) + if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) + if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) } return out } +interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean } + +// A plugin's declared dependencies as chips — a required addon (amber when that +// addon is disabled) or a plugin dependency (amber when missing / version-mismatched). +function deriveDeps(p: PluginRow, t: T): DepChip[] { + const out: DepChip[] = [] + const issues = p.dependencyIssues + for (const a of p.dependencies?.requiredAddons ?? []) { + out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) }) + } + for (const d of p.dependencies?.pluginDependencies ?? []) { + const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id)) + out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked }) + } + return out +} + function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) { const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks return @@ -135,6 +194,16 @@ function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) { ) } +/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ +function SideloadedBadge({ t }: { t: T }) { + return ( + + {t('admin.plugins.sideloaded')} + + ) +} + function TypeBadge({ type, t }: { type: string; t: T }) { return ( @@ -160,6 +229,8 @@ export default function AdminPluginsPanel() { // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) + // Open when enabling a plugin is blocked by missing/outdated plugin dependencies. + const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null) const [menu, setMenu] = useState(null) // Toolbar state. @@ -168,6 +239,11 @@ export default function AdminPluginsPanel() { const [statusFilter, setStatusFilter] = useState('all') const [sort, setSort] = useState('name') + // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button. + const [dragActive, setDragActive] = useState(false) + const uploadInputRef = useRef(null) + const dragDepth = useRef(0) + const refresh = () => { adminApi.plugins() .then((d: { enabled: boolean; plugins: PluginRow[] }) => { @@ -199,6 +275,43 @@ export default function AdminPluginsPanel() { setView('discover') if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([])) } + // The rescan/reload button rediscovers locally-installed plugins AND force-pulls + // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a + // just-published plugin shows up right away instead of up to ~35 min later. + const rescan = () => act('__rescan', async () => { + await adminApi.pluginRescan() + const items: RegistryItem[] = await adminApi.pluginBrowse(true) + setRegistry(items) + const map: Record = {} + items.forEach((i) => { if (i.latest) map[i.id] = i.latest }) + setLatest(map) + }, t('admin.plugins.rescanned')) + + // Sideload a plugin archive (installs INACTIVE — the admin still consents on activation). + const uploadPlugin = async (file: File) => { + setBusy('__upload'); setMenu(null) + try { + const res = await adminApi.pluginUpload(file) + setView('installed') + toast.success(t('admin.plugins.uploaded', { name: res.id })) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) + } finally { + setBusy(null); refresh() + } + } + const pickUpload = () => uploadInputRef.current?.click() + const onDragEnter = (e: DragEvent) => { + if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return + e.preventDefault(); dragDepth.current++; setDragActive(true) + } + const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } } + const onDrop = (e: DragEvent) => { + e.preventDefault(); dragDepth.current = 0; setDragActive(false) + if (!runtimeOn) return + const f = e.dataTransfer.files?.[0] + if (f) void uploadPlugin(f) + } const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -211,23 +324,63 @@ export default function AdminPluginsPanel() { const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) const installedIds = new Set(plugins.map(p => p.id)) + // Installed-but-disabled direct deps that enabling `p` will auto-enable first. + const autoEnabledDeps = (p: PluginRow) => + (p.dependencies?.pluginDependencies ?? []) + .map(d => plugins.find(x => x.id === d.id)) + .filter((x): x is PluginRow => !!x && x.enabled === 0) + .map(x => x.name) + + // Shared handling for a failed activation: route each 409 code to the right fix + // (consent dialog, download-dependency dialog, or a clear toast). + const onActivateError = (p: PluginRow, e: ActivateErr) => { + const d = e?.response?.data + if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { + setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) + } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') { + toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') })) + } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') { + setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] }) + } else { + // DEPENDENCY_CYCLE and everything else surface their server message. + toast.error(d?.error || t('admin.plugins.actionError')) + } + } + + const attemptActivate = (p: PluginRow) => { + const cascaded = autoEnabledDeps(p) + return adminApi.pluginActivate(p.id) + .then(() => { + toast.success(t('admin.plugins.activated')) + if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') })) + setDepResolve(null) + }) + .catch((e: ActivateErr) => onActivateError(p, e)) + } + // Enable/disable a plugin. Re-enabling one whose update widened its permissions - // must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we - // route through the same consent dialog the Update button uses. + // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled + // required addon or a missing plugin dependency (409 ADDON_DISABLED / + // DEPENDENCY_MISSING) routes to the right remedy. const toggle = (p: PluginRow) => { if (busy === p.id) return if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } setBusy(p.id); setMenu(null) - adminApi.pluginActivate(p.id) - .then(() => toast.success(t('admin.plugins.activated'))) - .catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => { - const d = e?.response?.data - if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { - setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) - } else { - toast.error(d?.error || t('admin.plugins.actionError')) - } + attemptActivate(p).finally(() => { setBusy(null); refresh() }) + } + + // Download a missing/outdated plugin dependency (latest compatible for its range, + // transitively), then retry enabling the plugin that needed it. + const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => { + if (busy === parent.id) return + setBusy(parent.id) + adminApi.pluginInstall(depId, { constraint, withDependencies: true }) + .then((r: { installed?: string[]; requiredAddons?: string[] }) => { + toast.success(t('admin.plugins.dep.downloaded', { id: depId })) + if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') })) + return attemptActivate(parent) }) + .catch((e: ActivateErr) => onActivateError(parent, e)) .finally(() => { setBusy(null); refresh() }) } @@ -285,7 +438,18 @@ export default function AdminPluginsPanel() { const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' return ( -
+
{ if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}> + {/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */} + { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} /> + {/* Drag-to-install overlay */} + {dragActive && ( +
+ + {t('admin.plugins.dropToUpload')} +
+ )} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} {menu &&
setMenu(null)} />} {/* Header */} @@ -324,10 +488,16 @@ export default function AdminPluginsPanel() { setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
- +
+ + +
@@ -344,6 +514,7 @@ export default function AdminPluginsPanel() { options={[ ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], + ['trip-page', t('admin.plugins.type.trip-page')], ]} valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} onPick={v => setTypeFilter(v as TypeFilter)} /> @@ -363,7 +534,11 @@ export default function AdminPluginsPanel() { valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} /> - + @@ -462,6 +637,14 @@ export default function AdminPluginsPanel() { onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} /> )} + + {depResolve && ( + resolveDependency(depResolve.plugin, depId, constraint)} + onClose={() => setDepResolve(null)} + /> + )}
) } @@ -541,6 +724,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) + const deps = deriveDeps(p, t) const menuOpen = menu === `row:${p.id}` return (
@@ -557,6 +741,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl {p.name} {p.version && v{p.version}} {p.reviewed_at && } + {p.source_repo === 'local:upload' && }
{p.description &&

{p.description}

} {p.status === 'error' && p.last_error ? ( @@ -573,6 +758,16 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl ))}
)} + {deps.length > 0 && ( +
+ {deps.map((d, i) => ( + + {d.label} + + ))} +
+ )}
@@ -597,7 +792,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl } label={t('admin.plugins.restart')} onClick={onRestart} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - {p.source_repo && ( + {p.source_repo && p.source_repo !== 'local:upload' && ( setMenu(null)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> {t('admin.plugins.sourceRepo')} @@ -875,6 +1070,57 @@ function UpdateConsentDialog({ data, t, onApprove, onLater }: { ) } +// Shown when enabling a plugin is blocked by missing/outdated plugin dependencies. +// Each dependency gets a one-click download (latest version satisfying its range, +// transitively) that then retries enabling the plugin. +function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onClose }: { + data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } + t: T; busy: boolean; installedIds: Set + onDownload: (depId: string, constraint?: string) => void; onClose: () => void +}) { + const rows: Array<{ id: string; constraint: string; installed?: string }> = [ + ...data.missing.map(d => ({ id: d.id, constraint: d.version })), + ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), + ] + return ( +
+
e.stopPropagation()}> +
+
+
+

{t('admin.plugins.dep.resolveTitle')}

+

{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}

+
+
+
+ {rows.map(r => ( +
+
+
{r.id}
+
+ {r.installed + ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed }) + : t('admin.plugins.dep.requires', { version: r.constraint })} +
+
+ +
+ ))} + {rows.some(r => !installedIds.has(r.id)) && ( +

{t('admin.plugins.dep.resolveHint')}

+ )} +
+
+ +
+
+
+ ) +} + // Footer: a plain-language note on what "Reviewed" means, plus a collapsible // panel that lays out how plugins are contained, the limits, and the worst case. function SecurityInfo({ t }: { t: T }) { diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 9c530a380..7899e2b83 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw' import { server } from '../../../tests/helpers/msw/server' import { useAuthStore } from '../../store/authStore' import { useTripStore } from '../../store/tripStore' +import { useSettingsStore } from '../../store/settingsStore' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories' import CostsPanel from './CostsPanel' @@ -164,6 +165,28 @@ describe('CostsPanel — settlements in the ledger', () => { expect(screen.getByText('Unfinished')).toBeInTheDocument() }) + it('sums only unfinished expenses in the Outstanding amount card', async () => { + // Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] } + const unfinishedA = { ...buildBudgetItem({ trip_id: 1, category: 'lodging', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + const unfinishedB = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 30, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + const zero = { ...buildBudgetItem({ trip_id: 1, category: 'misc', name: 'Freebie' }), total_price: 0, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, unfinishedA, unfinishedB, zero] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + ) + render() + + // Footer only shows the count once unfinished expenses have loaded. + const foot = await screen.findByText('expenses need a payer') + expect(foot).toHaveTextContent('2 expenses need a payer') // the two payer-less, non-zero expenses + // Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded. + // Sum is 90 + 30 = 120 — the paid (60) and zero-total items are excluded. + const card = screen.getByText('Outstanding amount').closest('div[style*="border-radius: 22"]') + expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30 + }) + it('records a recorded-total expense with nobody to split with (#1286)', async () => { let posted: Record | null = null server.use( diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 2a35aba18..d5e622ccb 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' @@ -179,6 +179,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const myShare = shares[me] || 0 return convert(myShare, curOf(e)) } + // "Unfinished": a recorded total nobody has paid yet — counts toward the trip + // total but stays out of settlements until who-paid is filled in. + const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0 const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) @@ -186,7 +189,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0) const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0) const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0) - return { totalSpend, myPaid, myShare, owe, owed } + const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0) + const outstandingCount = budgetItems.filter(isUnfinished).length + return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount } }, [budgetItems, settlement, me]) // ── filtering + day grouping ──────────────────────────────────────────── @@ -254,7 +259,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── settle actions ────────────────────────────────────────────────────── const settleFlow = async (fromId: number, toId: number, amount: number) => { try { - await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount }) + await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount, currency: base }) loadSettlement() } catch { toast.error(t('common.unknownError')) } } @@ -265,7 +270,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const flows = settlement?.flows || [] if (!flows.length) return try { - for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount }) + for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }) loadSettlement() } catch { toast.error(t('common.unknownError')) } } @@ -369,7 +374,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* ── Summary cards ── */} -
+
} tone="owe" foot={totals.owe > 0.01 @@ -380,6 +385,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps foot={totals.owed > 0.01 ? f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} /> : {t('costs.nothingOwed')}} /> + } tone="unfinished" + foot={totals.outstandingCount > 0 + ? {totals.outstandingCount} {t('costs.outstandingItems')} + : {t('costs.allSettled')}} /> } tone="total" foot={{t('costs.yourShare')} · {fmt0(totals.myShare)}{t('costs.youPaid')} · {fmt0(totals.myPaid)}} /> @@ -475,7 +485,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )} {(editingSettlement || addingPayment) && ( - { setEditingSettlement(null); setAddingPayment(false) }} onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )} @@ -511,9 +521,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps .costs-root .text-content-faint { color: var(--c-ink3) !important; } .costs-root .exp-actions { opacity: 1; } @media (max-width: 1100px) { - .costs-root .costs-summary { grid-template-columns: 1fr !important; } + .costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; } .costs-root .costs-grid { grid-template-columns: 1fr !important; } } + @media (max-width: 640px) { + .costs-root .costs-summary { grid-template-columns: 1fr !important; } + } `}
) @@ -580,6 +593,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
+ {/* Outstanding */} +
+
+
+
+
{t('costs.outstanding')}
+
{t('costs.outstandingSub')}
+
+
{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}
+
+
+ {/* Settle up */}
@@ -645,21 +670,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const cur = curOf(e) const payers = (e.payers || []).filter(p => p.amount > 0) const net = round2(myPaidOf(e) - myShareOf(e)) - // "Unfinished": a recorded total nobody has paid yet — counts toward the trip - // total but stays out of settlements until who-paid is filled in. - const isUnfinished = baseTotal(e) > 0 && payers.length === 0 + const unfinished = isUnfinished(e) return (
- {isMobile && isUnfinished && ( + {isMobile && unfinished && ( ! )}
{e.name} - {isUnfinished && !isMobile && ( + {unfinished && !isMobile && ( ! {t('costs.unfinished')} @@ -786,9 +809,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } // ── pure subcomponents ───────────────────────────────────────────────────── -function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) { +function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' | 'unfinished' }) { const total = tone === 'total' - const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined + const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : tone === 'unfinished' ? '#d97706' : undefined const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)' // formatToParts keeps the design's "big integer + muted symbol/decimals" styling // while letting Intl place the symbol and pick separators per locale + currency. @@ -835,8 +858,8 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A // Add or edit a settle-up payment (from / to / amount). Reachable inline from the // ledger row and from a manual "Add payment" button, so recording "I sent money to // X" works the same whether or not there's an outstanding expense behind it. -function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { - tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void +function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { + tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation() const toast = useToast() @@ -853,7 +876,7 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { const save = async () => { if (!valid) return setSaving(true) - const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt } + const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency } try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) diff --git a/client/src/components/Files/FileManager.helpers.test.ts b/client/src/components/Files/FileManager.helpers.test.ts new file mode 100644 index 000000000..dbb2bc6d3 --- /dev/null +++ b/client/src/components/Files/FileManager.helpers.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { isWalletPass } from './FileManager.helpers'; + +describe('isWalletPass (#1447)', () => { + it('detects by extension when the mime is unreliable', () => { + // Browsers frequently send octet-stream / empty for .pkpass uploads + expect(isWalletPass('application/octet-stream', 'boarding.pkpass')).toBe(true); + expect(isWalletPass(null, 'multi.pkpasses')).toBe(true); + expect(isWalletPass('', 'CAPS.PKPASS')).toBe(true); + }); + + it('falls back to the wallet MIME types when there is no extension', () => { + expect(isWalletPass('application/vnd.apple.pkpass', 'pass')).toBe(true); + expect(isWalletPass('application/vnd.apple.pkpasses', null)).toBe(true); + }); + + it('is false for non-wallet files', () => { + expect(isWalletPass('application/pdf', 'report.pdf')).toBe(false); + expect(isWalletPass('image/png', 'photo.png')).toBe(false); + expect(isWalletPass('text/markdown', 'notes.md')).toBe(false); + expect(isWalletPass(null, null)).toBe(false); + }); +}); diff --git a/client/src/components/Files/FileManager.helpers.ts b/client/src/components/Files/FileManager.helpers.ts index b9568e832..747f8fc0e 100644 --- a/client/src/components/Files/FileManager.helpers.ts +++ b/client/src/components/Files/FileManager.helpers.ts @@ -26,6 +26,18 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) { return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown') } +/** + * Apple Wallet pass (#1447). Detected by EXTENSION first — browsers often send an + * empty / octet-stream MIME for .pkpass — falling back to the wallet MIME types. + * Wallet passes must be downloaded so the OS hands them to Apple Wallet rather + * than rendered in the in-app PDF preview. + */ +export function isWalletPass(mimeType?: string | null, name?: string | null) { + const ext = (name || '').toLowerCase().split('.').pop() + if (ext === 'pkpass' || ext === 'pkpasses') return true + return !!mimeType && (mimeType === 'application/vnd.apple.pkpass' || mimeType === 'application/vnd.apple.pkpasses') +} + export function getFileIcon(mimeType?: string | null) { if (!mimeType) return File if (mimeType === 'application/pdf') return FileText diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index cd4dd40fc..478caf79f 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'), })); +// Mock the blob download/open helpers so we can assert wallet passes are +// downloaded (#1447) rather than opened in the in-app PDF preview. +vi.mock('../../utils/fileDownload', () => ({ + openFile: vi.fn().mockResolvedValue(undefined), + downloadFile: vi.fn().mockResolvedValue(undefined), +})); + +import { openFile as openFileInTab } from '../../utils/fileDownload'; + // Markdown pipeline mocked to render its children verbatim (the unified/ESM // pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal. vi.mock('react-markdown', () => ({ @@ -313,6 +322,21 @@ describe('FileManager', () => { }); }); + it('FE-COMP-FILEMANAGER-035: pkpass click downloads via blob helper, not the PDF preview (#1447)', async () => { + const files = [buildFile({ id: 1, mime_type: 'application/octet-stream', original_name: 'boarding.pkpass', url: '/uploads/trips/1/boarding.pkpass' })]; + render(); + const user = userEvent.setup(); + + await user.click(screen.getByText('boarding.pkpass')); + + // Blob helper is called with the file url + name — the OS hands it to Wallet + await waitFor(() => { + expect(openFileInTab).toHaveBeenCalledWith('/uploads/trips/1/boarding.pkpass', 'boarding.pkpass'); + }); + // No PDF preview modal — the filename appears only once (in the list row) + expect(screen.getAllByText('boarding.pkpass').length).toBe(1); + }); + it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => { const files = [buildFile({ uploaded_by_name: 'Alice Smith' })]; render(); diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 93d2e3fe3..0bfcb5cc3 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -7,7 +7,8 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { getAuthUrl } from '../../api/authUrl' -import { isImage, isMedia } from './FileManager.helpers' +import { isImage, isMedia, isWalletPass } from './FileManager.helpers' +import { openFile as openFileInTab } from '../../utils/fileDownload' export interface FileManagerProps { files?: TripFile[] @@ -191,6 +192,10 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place if (isMedia(file.mime_type)) { const idx = mediaFiles.findIndex(f => f.id === file.id) setLightboxIndex(idx >= 0 ? idx : 0) + } else if (isWalletPass(file.mime_type, file.original_name)) { + // Download so the OS hands the pass to Apple Wallet (#1447) rather than + // forcing it into the in-app PDF preview. + openFileInTab(file.url, file.original_name).catch(() => {}) } else { setPreviewFile(file) } diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 3c29bb8c0..a4763248d 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -129,6 +129,7 @@ beforeEach(() => { resetAllStores() vi.clearAllMocks() sessionStorage.clear() + localStorage.clear() // Reset mutable day-notes state mockDayNotesState.noteUi = {} mockDayNotesState.dayNotes = {} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index a97acda7b..d37dfdfa2 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -96,6 +96,9 @@ interface DayPlanSidebarProps { onScrollTopChange?: (top: number) => void /** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */ showRouteToolsWhenExpanded?: boolean + /** Mobile: drag & drop reorder is disabled (touch-scroll hijack, #1432); the + * grip handle is hidden and the arrow reorder buttons take over instead. */ + isMobile?: boolean } /** @@ -140,6 +143,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded = false, + isMobile = false, } = props const toast = useToast() const { t, language, locale } = useTranslation() @@ -153,7 +157,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [expandedDays, setExpandedDays] = useState(() => { try { - const saved = sessionStorage.getItem(`day-expanded-${tripId}`) + const saved = localStorage.getItem(`day-expanded-${tripId}`) if (saved) return new Set(JSON.parse(saved) as number[]) } catch {} return new Set(days.map(d => d.id)) @@ -260,7 +264,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { setExpandedDays(prev => { const n = new Set(prev) days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) }) - try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} + try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } @@ -293,7 +297,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { setExpandedDays(prev => { const n = new Set(prev) n.has(dayId) ? n.delete(dayId) : n.add(dayId) - try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} + try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } @@ -1019,6 +1023,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded, + isMobile, toast, t, language, @@ -1184,6 +1189,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded, + isMobile, toast, t, language, @@ -1648,9 +1654,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP return (
{ - if (!canEditDays) { e.preventDefault(); return } + if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('assignmentId', String(assignment.id)) e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' @@ -1745,7 +1751,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP opacity: isDraggingThis ? 0.4 : 1, }} > - {canEditDays &&
+ {canEditDays && !isMobile &&
}
{ - if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return } + if (!canEditDays || spanPhase === 'middle' || res.__leg || isMobile) { e.preventDefault(); return } // setData is required for the drag to start reliably (Firefox) and // matches how place/note items initiate their drag. e.dataTransfer.setData('reservationId', String(res.id)) @@ -2056,7 +2062,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, }} > - {canEditDays && spanPhase !== 'middle' && !res.__leg && ( + {canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
@@ -2173,8 +2179,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP return (
{ if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} + draggable={canEditDays && !isMobile} + onDragStart={e => { if (!canEditDays || isMobile) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDrop={e => { @@ -2242,7 +2248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', }} > - {canEditDays &&
+ {canEditDays && !isMobile &&
}
diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index 9178bd97b..38c0ae340 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -259,8 +259,8 @@ describe('PlaceInspector', () => { onRemoveAssignment={onRemoveAssignment} /> ); - // Find the remove button — it has "Remove" text (sm:hidden span) - const removeBtn = screen.getByText('Remove').closest('button')!; + // Find the remove button — it carries the "Remove from Day" label + const removeBtn = screen.getByText('Remove from Day').closest('button')!; await user.click(removeBtn); // Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id) expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 4d1684f98..35f6eccbf 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -8,7 +8,7 @@ import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus import PlaceAvatar from '../shared/PlaceAvatar' import GuestBadge from '../shared/GuestBadge' import StatusBadge from '../Collections/StatusBadge' -import { mapsApi } from '../../api/client' +import { mapsApi, pluginsApi } from '../../api/client' import { collectionsApi } from '../../api/collections' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' @@ -16,6 +16,8 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { getCategoryIcon } from '../shared/categoryIcons' import { useToast } from '../shared/Toast' import { useTranslation, translateApiError } from '../../i18n' +import { usePluginStore } from '../../store/pluginStore' +import PluginFrame from '../Plugins/PluginFrame' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { CollectionStatus } from '@trek/shared' import { splitReservationDateTime, formatTime } from '../../utils/formatters' @@ -142,6 +144,21 @@ export default function PlaceInspector({ leftWidth = 0, rightWidth = 0, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, }: PlaceInspectorProps) { + // Plugins that declared a place-detail slot mount at the bottom of this panel, + // scoped to the open place (trip mode only). Inline-filter like the other sites. + const placeDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'place-detail') + // Extra native rows contributed by placeDetailProvider plugins (#1429). Fail-safe: + // any provider error/timeout is dropped server-side, so this only ever adds rows. + const [providerDetails, setProviderDetails] = useState }>>([]) + const placeIdForDetails = mode === 'trip' ? place?.id : undefined + useEffect(() => { + if (placeIdForDetails == null) { setProviderDetails([]); return } + let cancelled = false + pluginsApi.placeDetails(placeIdForDetails) + .then((d) => { if (!cancelled) setProviderDetails((d.providers || []).filter((p) => Array.isArray(p.items) && p.items.length > 0)) }) + .catch(() => { if (!cancelled) setProviderDetails([]) }) + return () => { cancelled = true } + }, [placeIdForDetails]) const { t, locale, language } = useTranslation() const toast = useToast() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' @@ -356,6 +373,34 @@ export default function PlaceInspector({ fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} distanceUnit={distanceUnit} /> + {/* Extra native rows from placeDetailProvider plugins (#1429). */} + {mode === 'trip' && providerDetails.length > 0 && ( +
+ {providerDetails.flatMap((p) => p.items.map((it, i) => ( +
+ {it.label} + {it.url + ? {it.value ?? '↗'} + : {it.value}} +
+ )))} +
+ )} + + {/* Place-detail plugin slots (#1429): sandboxed, scoped to this place. */} + {mode === 'trip' && placeDetailPlugins.length > 0 && ( +
+ {placeDetailPlugins.map((p) => { + const tid = (place as { trip_id?: number | string }).trip_id + return ( +
+ +
+ ) + })} +
+ )} +
{/* Footer actions */} @@ -372,7 +417,7 @@ export default function PlaceInspector({ {mode === 'trip' && selectedDayId && ( assignmentInDay ? ( onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={} - label={<>{t('inspector.removeFromDay')}{t('inspector.remove')}} /> + label={{t('inspector.removeFromDay')}} /> ) : ( onAssignToDay?.(place.id)} variant="primary" icon={} label={t('inspector.addToDay')} /> ) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 06f93bc05..cefe35382 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -23,13 +23,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp } = S return (
- {sidebarDragOver && } + {!isMobile && sidebarDragOver && } {/* Kopfbereich */} diff --git a/client/src/components/Planner/PlacesSidebarRow.tsx b/client/src/components/Planner/PlacesSidebarRow.tsx index ff718c49e..0801b838e 100644 --- a/client/src/components/Planner/PlacesSidebarRow.tsx +++ b/client/src/components/Planner/PlacesSidebarRow.tsx @@ -36,8 +36,9 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({ ref={element => registerPlaceRow(place.id, element)} aria-selected={isSelected} data-place-id={place.id} - draggable={!selectMode} + draggable={!selectMode && !isMobile} onDragStart={e => { + if (isMobile) { e.preventDefault(); return } e.dataTransfer.setData('placeId', String(place.id)) e.dataTransfer.effectAllowed = 'copy' window.__dragData = { placeId: String(place.id) } @@ -55,7 +56,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({ style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px 9px 16px', - cursor: selectMode ? 'pointer' : 'grab', + cursor: selectMode || isMobile ? 'pointer' : 'grab', background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent', borderBottom: '1px solid var(--border-faint)', transition: 'background 0.1s', diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index a4d1209c1..f1f2a7bfc 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -409,6 +409,12 @@ describe('ReservationModal', () => { expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); }); + it('FE-PLANNER-RESMODAL-029b: file input accepts pkpass (#1448)', () => { + render(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(fileInput.accept).toContain('.pkpass'); + }); + it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 05960f994..f8a775f1f 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -682,7 +682,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
))} - +
{onFileUpload &&
))} - +
{onFileUpload &&
+

The frame runs sandboxed at an opaque origin, exactly like TREK — trek.invoke() is proxied to your /api routes as the dev user.

+
+ +`; +} diff --git a/plugin-sdk/src/cli/menu.ts b/plugin-sdk/src/cli/menu.ts new file mode 100644 index 000000000..4b2ac7941 --- /dev/null +++ b/plugin-sdk/src/cli/menu.ts @@ -0,0 +1,70 @@ +/** + * The top-level menu shown when `trek-plugin-sdk` is run with no command in a + * terminal (#plugins). It only picks WHICH command to run — the dispatcher then + * runs it through the same interactive path a named command would take, so each + * command still prompts for whatever it needs. + */ +import { intro, outro, promptSelect } from './ui.js'; + +export interface MenuItem { + value: string; + label: string; + hint: string; +} + +/** The everyday author journey. `advanced`/`exit` are control entries. */ +export const PRIMARY_MENU: MenuItem[] = [ + { value: 'create', label: 'Create a plugin', hint: 'Scaffold a new plugin' }, + { value: 'dev', label: 'Run the dev server', hint: 'Live-reload your plugin locally' }, + { value: 'validate', label: 'Validate', hint: 'Check the manifest + layout' }, + { value: 'pack', label: 'Pack', hint: 'Build plugin.zip' }, + { value: 'publish', label: 'Publish', hint: 'Release + open the registry PR' }, + { value: 'advanced', label: 'Advanced…', hint: 'Signing, registry entry, preflight' }, + { value: 'exit', label: 'Exit', hint: '' }, +]; + +/** The less-common signing/registry commands, behind the `advanced` entry. */ +export const ADVANCED_MENU: MenuItem[] = [ + { value: 'keygen', label: 'Generate a signing key', hint: 'Create an Ed25519 signing key' }, + { value: 'sign', label: 'Sign an artifact', hint: 'Print a signature + public key' }, + { value: 'entry', label: 'Registry entry', hint: 'Print the ready-to-PR registry JSON' }, + { value: 'preflight', label: 'Preflight', hint: 'Run the registry CI checks locally' }, + { value: 'submit', label: 'Submit', hint: 'Open the registry PR' }, + { value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' }, + { value: 'back', label: '← Back', hint: '' }, +]; + +/** Every value the menu can yield, control entries included. */ +const MENU_VALUES = new Set([...PRIMARY_MENU, ...ADVANCED_MENU].map((m) => m.value)); + +/** Pure: a menu value maps to itself when known, else `undefined`. Unit-tested. */ +export function resolveMenuChoice(value: string): string | undefined { + return MENU_VALUES.has(value) ? value : undefined; +} + +const toOptions = (items: MenuItem[]) => + items.map(({ value, label, hint }) => (hint ? { value, label, hint } : { value, label })); + +/** + * Show the menu and return the chosen command name for the dispatcher to run, + * or `null` if the user chose Exit. Loops back from the Advanced submenu. + */ +export async function runMenu(): Promise { + intro('trek-plugin-sdk'); + for (;;) { + const choice = await promptSelect({ + message: 'What would you like to do?', + options: toOptions(PRIMARY_MENU), + }); + if (choice === 'exit') { + outro('Nothing to do — bye!'); + return null; + } + if (choice === 'advanced') { + const adv = await promptSelect({ message: 'Advanced commands', options: toOptions(ADVANCED_MENU) }); + if (adv === 'back') continue; + return adv; + } + return choice; + } +} diff --git a/plugin-sdk/src/cli/pack.ts b/plugin-sdk/src/cli/pack.ts index 02d215d3e..de1371951 100644 --- a/plugin-sdk/src/cli/pack.ts +++ b/plugin-sdk/src/cli/pack.ts @@ -12,6 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { makeZip, type ZipInput } from '../zip.js'; +import { injectTrekUi } from '../ui/kit.js'; import { validatePluginDir } from './validate.js'; const MAX_TOTAL = 50 * 1024 * 1024; @@ -41,7 +42,14 @@ function walk(base: string, rel: string, out: ZipInput[]): void { walk(base, childRel, out); } else if (entry.isFile()) { if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue; - out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) }); + // Expand the `` marker into the inlined design kit as the file + // enters the archive, so the source stays a one-line opt-in. A no-op on HTML + // without the marker, so it's safe to run over every .html. + if (entry.name.endsWith('.html')) { + out.push({ name: childRel, data: Buffer.from(injectTrekUi(fs.readFileSync(path.join(base, childRel), 'utf8')), 'utf8') }); + } else { + out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) }); + } } // symlinks and other non-regular entries are skipped (never shipped) } diff --git a/plugin-sdk/src/cli/publish.ts b/plugin-sdk/src/cli/publish.ts index 5efb4f2d7..11c84b9e2 100644 --- a/plugin-sdk/src/cli/publish.ts +++ b/plugin-sdk/src/cli/publish.ts @@ -14,6 +14,7 @@ import { packPluginDir } from './pack.js'; import { buildEntry } from './entry.js'; import { preflight } from './preflight.js'; import { submitEntry } from './submit.js'; +import { plainLog, type LogSink } from './ui.js'; function git(dir: string, args: string[], quiet = true): string { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' }).toString().trim(); @@ -25,15 +26,18 @@ function tagExists(dir: string, tag: string): boolean { export async function publishPlugin(opts: { dir: string; repo: string; tag: string; now: string; signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean; + /** Progress sink. Defaults to the plain console.error lines (CI parity). */ + log?: LogSink; }): Promise<{ prUrl: string }> { const dir = path.resolve(opts.dir); - const step = (n: number, msg: string) => console.error(`[${n}/4] ${msg}`); + const log = opts.log ?? plainLog; + const step = (n: number, msg: string) => log(`[${n}/4] ${msg}`); // 1. Pack step(1, 'Packing the artifact…'); const zip = path.join(dir, 'plugin.zip'); const packed = packPluginDir(dir, zip); - console.error(` ✓ ${packed.files.length} files, ${packed.size} bytes`); + log(` ✓ ${packed.files.length} files, ${packed.size} bytes`); // 2. Tag (if needed) + push + GitHub release with the artifact attached step(2, `Tagging ${opts.tag} + creating the GitHub release…`); @@ -47,24 +51,24 @@ export async function publishPlugin(opts: { // Release already exists — (re)upload the packed artifact so its bytes match the pin. execFileSync('gh', ['release', 'upload', opts.tag, packed.artifact, '--repo', opts.repo, '--clobber'], { stdio: 'pipe' }); } - console.error(` ✓ release ${opts.tag} on ${opts.repo}`); + log(` ✓ release ${opts.tag} on ${opts.repo}`); // 3. Build the entry, then run the registry CI checks locally const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now }); if (opts.skipPreflight) { - console.error('[3/4] Preflight skipped (--no-preflight).'); + log('[3/4] Preflight skipped (--no-preflight).'); } else { step(3, 'Preflight — running the registry CI checks…'); const rep = await preflight(entry); - for (const f of rep.failures) console.error(' ✗ ' + f); + for (const f of rep.failures) log(' ✗ ' + f); if (!rep.ok) throw new Error(`preflight found ${rep.failures.length} problem(s) — fix these and re-run (nothing was submitted). Did you push your code to ${opts.repo} before publishing?`); - console.error(` ✓ all ${rep.passed.length} checks passed`); + log(` ✓ all ${rep.passed.length} checks passed`); } // 4. Open the registry PR step(4, 'Opening the registry PR…'); const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft }); - console.error(' ✓ done'); + log(' ✓ done'); fs.rmSync(zip, { force: true }); return { prUrl }; } diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index 9d9282649..418c4b8e2 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -34,6 +34,11 @@ import { submitEntry } from './submit.js'; import { publishPlugin } from './publish.js'; import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js'; import { readJsonFile } from './json.js'; +import { + isInteractive, intro, outro, note, logInfo, logSuccess, logWarn, logError, spinner, + promptText, promptConfirm, clackLogSink, missingArgs, +} from './ui.js'; +import { runMenu } from './menu.js'; const [cmd, ...args] = process.argv.slice(2); @@ -57,116 +62,210 @@ function fail(msg: string): never { const { flags, pos } = parse(args); +type Flags = Record; + /** --sign, --sign , or absent → the key path to sign with (or undefined). */ -function signKey(): string | undefined { - if (!flags.sign) return undefined; - return flags.sign === 'true' ? (flags.key || defaultKeyPath()) : flags.sign; +function signKey(f: Flags): string | undefined { + if (!f.sign) return undefined; + return f.sign === 'true' ? (f.key || defaultKeyPath()) : f.sign; } +/** + * Resolve the repo + tag every publishing command needs. Interactive: prompt for + * whatever is missing. Non-interactive: reproduce the command's exact error so + * scripts/CI behave exactly as before. + */ +async function ensureRepoTag(f: Flags, failMsg: string): Promise<{ repo: string; tag: string }> { + if (missingArgs(f, ['repo', 'tag']).length === 0) return { repo: f.repo, tag: f.tag }; + if (!isInteractive()) fail(failMsg); + const repo = f.repo || await promptText({ + message: 'GitHub repo (owner/name)', placeholder: 'you/trek-plugin-thing', + validate: (v) => (/^[^/\s]+\/[^/\s]+$/.test((v ?? '').trim()) ? undefined : 'format: owner/name'), + }); + const tag = f.tag || await promptText({ + message: 'Release tag', placeholder: 'v1.0.0', + validate: (v) => (/^v\d+\.\d+\.\d+/.test((v ?? '').trim()) ? undefined : 'format: vX.Y.Z'), + }); + return { repo: repo.trim(), tag: tag.trim() }; +} + +const USAGE = 'usage: trek-plugin [...]'; + async function main(): Promise { - if (cmd === 'create') { - const name = pos[0]; - if (!name || flags.interactive) { - const created = await interactiveScaffold(process.cwd(), name); - console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${created}\`.`); + if (!cmd) { + // Bare invocation: a menu in a terminal, the usage line for scripts. + if (!isInteractive()) { console.error(USAGE); process.exit(2); } + const chosen = await runMenu(); + if (chosen) await dispatch(chosen, {}, []); + return; + } + await dispatch(cmd, flags, pos); +} + +async function dispatch(command: string, f: Flags, positional: string[]): Promise { + const tui = isInteractive(); + if (command === 'create') { + const name = positional[0]; + if (!name || f.interactive) { + if (!tui) fail('create needs a plugin name in non-interactive mode: create [--type integration|page|widget]'); + await interactiveScaffold(process.cwd(), name); return; } - scaffold(name, flags.type || 'integration', process.cwd(), { - author: flags.author, description: flags.description, - permissions: flags.permissions ? flags.permissions.split(/[\s,]+/).filter(Boolean) : undefined, + scaffold(name, f.type || 'integration', process.cwd(), { + author: f.author, description: f.description, + permissions: f.permissions ? f.permissions.split(/[\s,]+/).filter(Boolean) : undefined, }); console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`); - } else if (cmd === 'dev') { - await runDev(pos[0] || '.', { port: flags.port ? Number(flags.port) : undefined }); - } else if (cmd === 'validate') { - const r = validatePluginDir(pos[0] || '.'); - for (const w of r.warnings) console.warn('warning: ' + w); - if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); } - console.log('✓ plugin is valid'); - } else if (cmd === 'pack') { - const r = packPluginDir(pos[0] || '.', flags.out || 'plugin.zip'); - if (flags.json) { - console.log(JSON.stringify(r, null, 2)); + } else if (command === 'dev') { + if (tui) intro(`trek-plugin dev — ${positional[0] || '.'}`); + await runDev(positional[0] || '.', { port: f.port ? Number(f.port) : undefined }); + } else if (command === 'validate') { + const r = validatePluginDir(positional[0] || '.'); + if (tui) { + for (const w of r.warnings) logWarn(w); + if (!r.ok) { for (const e of r.errors) logError(e); outro('✗ plugin has errors'); process.exit(1); } + outro('✓ plugin is valid'); } else { - console.log(`Packed ${r.files.length} files -> ${path.relative(process.cwd(), r.artifact) || r.artifact}`); - for (const f of r.files) console.log(' ' + f); + for (const w of r.warnings) console.warn('warning: ' + w); + if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); } + console.log('✓ plugin is valid'); + } + } else if (command === 'pack') { + const r = packPluginDir(positional[0] || '.', f.out || 'plugin.zip'); + const rel = path.relative(process.cwd(), r.artifact) || r.artifact; + if (f.json) { + console.log(JSON.stringify(r, null, 2)); // machine output — never decorated + } else if (tui) { + note([...r.files, '', `sha256: ${r.sha256}`, `size: ${r.size}`].join('\n'), `Packed ${r.files.length} files → ${rel}`); + logInfo('Upload plugin.zip to your release, then run `npx trek-plugin-sdk entry`.'); + } else { + console.log(`Packed ${r.files.length} files -> ${rel}`); + for (const file of r.files) console.log(' ' + file); console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`); console.log('\nUpload this plugin.zip to your release, then run `npx trek-plugin-sdk entry` to generate the registry entry.'); } - } else if (cmd === 'keygen') { - const keyPath = flags.key || defaultKeyPath(); + } else if (command === 'keygen') { + const keyPath = f.key || defaultKeyPath(); const { publicKey } = generateKeypair(keyPath); - console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); - console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); - console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); - } else if (cmd === 'sign') { - const zip = pos[0] || 'plugin.zip'; + if (tui) { + note(`Signing key written to ${keyPath}\nKeep it safe + BACK IT UP — losing it means you can't ship signed updates.\n\nauthorPublicKey (goes in your registry entry):\n${publicKey}`, 'Signing key'); + logInfo('Sign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); + } else { + console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); + console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); + console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); + } + } else if (command === 'sign') { + const zip = positional[0] || 'plugin.zip'; if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`npx trek-plugin-sdk pack\` first`); - const key = loadPrivateKey(flags.key || defaultKeyPath()); + const key = loadPrivateKey(f.key || defaultKeyPath()); const buf = fs.readFileSync(zip); - console.log(`signature: ${signArtifact(buf, key)}`); - console.log(`authorPublicKey: ${publicKeyBase64(key)}`); - } else if (cmd === 'entry') { - if (!flags.repo || !flags.tag) fail('entry needs --repo and --tag '); + if (tui) { + note(`signature: ${signArtifact(buf, key)}\nauthorPublicKey: ${publicKeyBase64(key)}`, `Signed ${zip}`); + } else { + console.log(`signature: ${signArtifact(buf, key)}`); + console.log(`authorPublicKey: ${publicKeyBase64(key)}`); + } + } else if (command === 'entry') { + const { repo, tag } = await ensureRepoTag(f, 'entry needs --repo and --tag '); const entry = buildEntry({ - dir: flags.dir || pos[0] || '.', - repo: flags.repo, tag: flags.tag, - zipPath: flags.zip || 'plugin.zip', - commit: flags.commit, asset: flags.asset, mergePath: flags.merge, - signKeyPath: signKey(), - now: new Date().toISOString(), + dir: f.dir || positional[0] || '.', repo, tag, + zipPath: f.zip || 'plugin.zip', + commit: f.commit, asset: f.asset, mergePath: f.merge, + signKeyPath: signKey(f), now: new Date().toISOString(), }); const json = JSON.stringify(entry, null, 2) + '\n'; - if (flags.out) { - fs.writeFileSync(flags.out, json); - console.error(`Wrote ${flags.out} — add it as registry/plugins/${entry.id}.json in a TREK-Plugins PR.`); + if (f.out) { + fs.writeFileSync(f.out, json); + const msg = `Wrote ${f.out} — add it as registry/plugins/${entry.id}.json in a TREK-Plugins PR.`; + if (tui) logSuccess(msg); else console.error(msg); } else { - process.stdout.write(json); + process.stdout.write(json); // machine output on stdout — never decorated + } + } else if (command === 'preflight') { + let entry: ReturnType; + if (f.entry) { + entry = readJsonFile>(f.entry); + } else { + const { repo, tag } = await ensureRepoTag(f, 'preflight needs --repo --tag , or --entry '); + entry = buildEntry({ dir: positional[0] || '.', repo, tag, zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString() }); + } + if (tui) { + const s = spinner(); s.start('Running the registry CI checks over the network'); + const rep = await preflight(entry, { all: !!f.all }); + s.stop('Checks complete'); + for (const p of rep.passed) logSuccess(p); + for (const fa of rep.failures) logError(fa); + if (!rep.ok) { outro(`${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } + outro('✓ all checks passed — this entry should sail through CI.'); + } else { + console.error('Running registry CI checks over the network…\n'); + const rep = await preflight(entry, { all: !!f.all }); + for (const p of rep.passed) console.error(' ✓ ' + p); + for (const fa of rep.failures) console.error(' ✗ ' + fa); + if (!rep.ok) { console.error(`\n${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } + console.error('\n✓ all checks passed — this entry should sail through CI.'); + } + } else if (command === 'publish') { + const { repo, tag } = await ensureRepoTag(f, 'publish needs --repo and --tag '); + if (tui) { + note(`repo ${repo}\ntag ${tag}\ndir ${positional[0] || '.'}`, 'Publish'); + const ok = await promptConfirm({ message: 'Create the GitHub release and open the registry PR?', initialValue: true }); + if (!ok) { outro('Cancelled — nothing was published.'); return; } } - } else if (cmd === 'preflight') { - const entry = flags.entry - ? readJsonFile>(flags.entry) - : (flags.repo && flags.tag - ? buildEntry({ dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), now: new Date().toISOString() }) - : fail('preflight needs --repo --tag , or --entry ')); - console.error('Running registry CI checks over the network…\n'); - const rep = await preflight(entry, { all: !!flags.all }); - for (const p of rep.passed) console.error(' ✓ ' + p); - for (const f of rep.failures) console.error(' ✗ ' + f); - if (!rep.ok) { console.error(`\n${rep.failures.length} check(s) would fail CI — fix these before submitting.`); process.exit(1); } - console.error('\n✓ all checks passed — this entry should sail through CI.'); - } else if (cmd === 'publish') { - if (!flags.repo || !flags.tag) fail('publish needs --repo and --tag '); const { prUrl } = await publishPlugin({ - dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, - signKeyPath: signKey(), registry: flags.registry, draft: !!flags.draft, - notes: flags.notes, skipPreflight: !!flags['no-preflight'], now: new Date().toISOString(), + dir: positional[0] || '.', repo, tag, + signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft, + notes: f.notes, skipPreflight: !!f['no-preflight'], now: new Date().toISOString(), + log: tui ? clackLogSink : undefined, }); - console.error('\n✓ published — registry PR:'); - console.log(prUrl); - } else if (cmd === 'submit') { - if (!flags.repo || !flags.tag) fail('submit needs --repo and --tag '); + if (tui) logSuccess('Published — registry PR:'); else console.error('\n✓ published — registry PR:'); + console.log(prUrl); // machine output on stdout + } else if (command === 'submit') { + const { repo, tag } = await ensureRepoTag(f, 'submit needs --repo and --tag '); const entry = buildEntry({ - dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, - zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), - now: new Date().toISOString(), + dir: positional[0] || '.', repo, tag, + zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString(), }); - console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`); - const { prUrl } = submitEntry(entry, { registry: flags.registry, branch: flags.branch, draft: !!flags.draft, keep: !!flags.keep }); - console.log(prUrl); - } else if (cmd === 'release') { - if (!flags.repo || !flags.tag) fail('release needs --repo and --tag '); - const dir = pos[0] || '.'; - const zip = path.resolve(dir, flags.out || 'plugin.zip'); - const packed = packPluginDir(dir, zip); - console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`); - console.error(`Creating GitHub release ${flags.tag} on ${flags.repo}…`); - execFileSync('gh', ['release', 'create', flags.tag, packed.artifact, '--repo', flags.repo, '--title', flags.tag, '--notes', flags.notes || `Release ${flags.tag}`], { stdio: 'inherit' }); - const entry = buildEntry({ dir, repo: flags.repo, tag: flags.tag, zipPath: packed.artifact, commit: flags.commit, mergePath: flags.merge, signKeyPath: signKey(), now: new Date().toISOString() }); - console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n'); - process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); + if (tui) { + note(`${entry.id} ${entry.versions[0].version}\nrepo ${repo}`, 'Submit registry PR'); + const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true }); + if (!ok) { outro('Cancelled — no PR opened.'); return; } + const s = spinner(); s.start('Opening the registry PR'); + const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep }); + s.stop('Registry PR opened'); + console.log(prUrl); + } else { + console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`); + const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep }); + console.log(prUrl); + } + } else if (command === 'release') { + const { repo, tag } = await ensureRepoTag(f, 'release needs --repo and --tag '); + const dir = positional[0] || '.'; + const zip = path.resolve(dir, f.out || 'plugin.zip'); + if (tui) { + const packed = packPluginDir(dir, zip); + note(`packed ${packed.files.length} files (${packed.size} bytes)\nrepo ${repo}\ntag ${tag}`, 'Release'); + const ok = await promptConfirm({ message: `Create GitHub release ${tag} on ${repo}?`, initialValue: true }); + if (!ok) { outro('Cancelled — no release created.'); return; } + const s = spinner(); s.start(`Creating GitHub release ${tag}`); + execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'pipe' }); + s.stop(`Released ${tag} on ${repo}`); + const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() }); + logInfo(`Registry entry (add as registry/plugins/${entry.id}.json, or run \`npx trek-plugin-sdk submit\`):`); + process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); + } else { + const packed = packPluginDir(dir, zip); + console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`); + console.error(`Creating GitHub release ${tag} on ${repo}…`); + execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'inherit' }); + const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() }); + console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n'); + process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); + } } else { - console.error('usage: trek-plugin [...]'); + console.error(USAGE); process.exit(2); } } diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts new file mode 100644 index 000000000..37903f94f --- /dev/null +++ b/plugin-sdk/src/cli/ui.ts @@ -0,0 +1,122 @@ +/** + * The one Clack styling layer for the plugin-author CLI (#plugins). + * + * Two rules make this safe to sprinkle across every command: + * 1. Nothing here runs unless `isInteractive()` — in CI / pipes the commands fall + * back to their plain, flag-driven behaviour with byte-identical output. + * 2. ALL decoration and prompts render to STDERR, so stdout stays a pure data + * channel (the `entry` JSON, `pack --json`, PR URLs) that can be piped. + */ +import { + intro as clackIntro, + outro as clackOutro, + note as clackNote, + log as clackLog, + spinner as clackSpinner, + cancel as clackCancel, + isCancel, + text as clackText, + select as clackSelect, + multiselect as clackMultiselect, + confirm as clackConfirm, + type TextOptions, + type SelectOptions, + type MultiSelectOptions, + type ConfirmOptions, + type SpinnerResult, +} from '@clack/prompts'; + +/** Every prompt and every decoration renders here, never on stdout. */ +const OUT = process.stderr; + +/** True only when a human is driving a real terminal on both stdin and stdout. */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +// ── decoration ───────────────────────────────────────────────────────────── + +export const intro = (title: string): void => clackIntro(title, { output: OUT }); +export const outro = (message: string): void => clackOutro(message, { output: OUT }); +export const note = (message: string, title?: string): void => clackNote(message, title, { output: OUT }); +export const logInfo = (m: string): void => clackLog.info(m, { output: OUT }); +export const logSuccess = (m: string): void => clackLog.success(m, { output: OUT }); +export const logWarn = (m: string): void => clackLog.warn(m, { output: OUT }); +export const logError = (m: string): void => clackLog.error(m, { output: OUT }); +export const spinner = (): SpinnerResult => clackSpinner({ output: OUT }); + +// ── cancellation ───────────────────────────────────────────────────────────── + +/** Clack returns a symbol when the user hits Ctrl+C — turn that into a clean exit. */ +export function orCancel(value: T | symbol): T { + if (isCancel(value)) { + clackCancel('Cancelled.', { output: OUT }); + process.exit(0); + } + return value as T; +} + +// ── prompts (cancel-checked, stderr-rendered) ──────────────────────────────── + +export const promptText = async (opts: Omit): Promise => + orCancel(await clackText({ ...opts, output: OUT })); + +export const promptSelect = async (opts: Omit, 'output'>): Promise => + orCancel(await clackSelect({ ...opts, output: OUT })); + +export const promptMultiselect = async (opts: Omit, 'output'>): Promise => + orCancel(await clackMultiselect({ ...opts, output: OUT })); + +export const promptConfirm = async (opts: Omit): Promise => + orCancel(await clackConfirm({ ...opts, output: OUT })); + +// ── permissions ────────────────────────────────────────────────────────────── + +/** + * The permissions an author can grant, each with a human hint for the multiselect. + * This list must stay in lockstep with the host's `KNOWN_PERMISSIONS` + * (server `src/nest/plugins/protocol/envelope.ts`) — same strings, same order. + * The manifest validator's `KNOWN_PERMISSIONS` (`../manifest.ts`) mirrors it too. + */ +export const PERMISSION_CATALOG: { value: string; label: string; hint: string }[] = [ + { value: 'db:own', label: 'db:own', hint: 'A private database only this plugin can read/write' }, + { value: 'db:read:trips', label: 'db:read:trips', hint: 'Read trips the requesting user can access' }, + { value: 'db:read:users', label: 'db:read:users', hint: 'Read basic user profiles' }, + { value: 'db:read:costs', label: 'db:read:costs', hint: 'Read costs (budget items) the requesting user can access' }, + { value: 'db:write:costs', label: 'db:write:costs', hint: 'Create costs (budget items) on trips the user can edit' }, + { value: 'db:write:places', label: 'db:write:places', hint: 'Add, edit and remove places on trips the user can edit' }, + { value: 'db:write:days', label: 'db:write:days', hint: 'Add, edit and remove days on trips the user can edit' }, + { value: 'db:write:itinerary', label: 'db:write:itinerary', hint: 'Assign and remove places on days of trips the user can edit' }, + { value: 'db:write:trips', label: 'db:write:trips', hint: 'Edit trip details (title, dates, currency…) on trips the user can edit' }, + { value: 'db:meta', label: 'db:meta', hint: 'Attach its own private data to trips, places and days the user can access' }, + { value: 'ws:broadcast:trip', label: 'ws:broadcast:trip', hint: 'Push realtime events to everyone on a trip' }, + { value: 'ws:broadcast:user', label: 'ws:broadcast:user', hint: 'Push realtime events to a single user' }, + { value: 'hook:photo-provider', label: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, + { value: 'hook:calendar-source', label: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, + { value: 'hook:place-detail-provider', label: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, + { value: 'hook:trip-warning-provider', label: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, + { value: 'http:outbound', label: 'http:outbound', hint: 'Call external HTTP hosts (needs an egress allow-list)' }, +]; + +export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value); + +// ── reporter seam (for commands that log their own progress, e.g. publish) ──── + +/** A progress sink. The default writes the same plain lines as before; the + * interactive one routes through Clack styling. Keeping this an injectable + * `log(msg)` means the tested publish path is byte-identical by default. */ +export type LogSink = (msg: string) => void; + +/** Non-interactive default: exactly the previous `console.error` behaviour. */ +export const plainLog: LogSink = (msg: string) => console.error(msg); + +/** Interactive sink: same text, rendered as a Clack step on stderr. */ +export const clackLogSink: LogSink = (msg: string) => clackLog.step(msg.trim(), { output: OUT }); + +// ── flag helpers ────────────────────────────────────────────────────────────── + +/** Pure: which of `required` flags are absent. Drives both the interactive + * prompt (fill the gaps) and the non-interactive error (report them). */ +export function missingArgs(flags: Record, required: string[]): string[] { + return required.filter((k) => !flags[k]); +} diff --git a/plugin-sdk/src/cli/validate.ts b/plugin-sdk/src/cli/validate.ts index ad4c2f192..c044e1564 100644 --- a/plugin-sdk/src/cli/validate.ts +++ b/plugin-sdk/src/cli/validate.ts @@ -7,7 +7,7 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { validateManifest } from '../manifest.js'; +import { validateManifest, KNOWN_ADDONS } from '../manifest.js'; import { readJsonFile } from './json.js'; export interface ValidateReport { @@ -33,6 +33,11 @@ export function validatePluginDir(dir: string): ValidateReport { if (manifestId && path.basename(path.resolve(dir)) !== manifestId) { warnings.push(`directory name should equal the plugin id "${manifestId}"`); } + // A well-formed but unrecognised addon id is not an error (the plugin may target + // a newer TREK), but it can never enable here — surface it as a warning. + for (const a of res.manifest?.requiredAddons ?? []) { + if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`); + } } catch (e) { errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e)); } diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index aa79f11ca..50435272a 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -9,6 +9,20 @@ /** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */ export const PLUGIN_API_VERSION = 1 as const; +// Core entity shapes returned by ctx reads/writes. Only `id` is guaranteed; the rest +// are the fields plugins most commonly use (typed for autocomplete), left optional +// because they mirror raw DB rows — and every shape keeps an index signature, so no +// column is ever hidden from you. +export interface Trip { id: number; user_id?: number; title?: string; start_date?: string | null; end_date?: string | null; currency?: string | null; [k: string]: unknown } +export interface Place { id: number; trip_id?: number; name?: string; lat?: number | null; lng?: number | null; day_id?: number | null; category_id?: number | null; notes?: string | null; [k: string]: unknown } +export interface Day { id: number; trip_id?: number; date?: string | null; title?: string | null; [k: string]: unknown } +export interface Reservation { id: number; trip_id?: number; type?: string; [k: string]: unknown } +export interface PackingItem { id: number; trip_id?: number; name?: string; [k: string]: unknown } +export interface TripFile { id: number; trip_id?: number; filename?: string; [k: string]: unknown } +export interface BudgetItem { id: number; trip_id?: number; name?: string; total_price?: number | null; currency?: string | null; [k: string]: unknown } +export interface Assignment { id: number; day_id?: number; place_id?: number; notes?: string | null; [k: string]: unknown } +export interface User { id: number; username?: string; display_name?: string | null; avatar?: string | null; [k: string]: unknown } + export interface PluginContext { readonly id: string; readonly config: Readonly>; @@ -18,11 +32,58 @@ export interface PluginContext { migrate(id: string, sql: string): Promise<{ applied: boolean }>; }; trips: { - getById(tripId: number, asUserId: number): Promise; - getPlaces(tripId: number, asUserId: number): Promise; - getReservations(tripId: number, asUserId: number): Promise; + getById(tripId: number, asUserId?: number): Promise; + getPlaces(tripId: number, asUserId?: number): Promise; + getReservations(tripId: number, asUserId?: number): Promise; + /** Update trip fields; needs `db:write:trips` + the acting user's trip_edit permission. Route context only. */ + update(tripId: number, input: Record): Promise; }; - users: { getById(id: number): Promise }; + // Read-only views of other trip subsystems (#1429 eco). Membership-checked against + // the current user; each needs its own db:read:* scope. + packing: { + /** A trip's packing items (hydrated bags/assignees). Needs `db:read:packing`. */ + list(tripId: number): Promise; + }; + files: { + /** A trip's files, trash excluded. Needs `db:read:files`. */ + list(tripId: number): Promise; + }; + // "Costs" = budget items. The acting user is bound by the host to the current + // invocation; create/update/delete also need 'budget_edit' and the Costs addon + // enabled. + costs: { + getByTrip(tripId: number): Promise; + listMine(): Promise; + create(tripId: number, input: Record): Promise; + update(tripId: number, itemId: number, input: Record): Promise; + delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>; + }; + // Core planner writes (#1429). Membership-checked against the invocation's user; + // each needs the matching write scope + the app's place_edit/day_edit permission. + places: { + create(tripId: number, input: Record): Promise; + update(tripId: number, placeId: number, input: Record): Promise; + delete(tripId: number, placeId: number): Promise<{ deleted: boolean }>; + }; + days: { + create(tripId: number, input: Record): Promise; + update(tripId: number, dayId: number, input: Record): Promise; + delete(tripId: number, dayId: number): Promise<{ deleted: boolean }>; + }; + itinerary: { + assign(tripId: number, dayId: number, placeId: number, notes?: string | null): Promise; + unassign(tripId: number, assignmentId: number): Promise<{ deleted: boolean }>; + }; + // Your OWN namespaced key/value store on a trip/place/day (#1429) — enrich core + // entities without forking the schema. Needs `db:meta`; 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; + set(entityType: 'trip' | 'place' | 'day', entityId: number, key: string, value: unknown): Promise; + list(entityType: 'trip' | 'place' | 'day', entityId: number): Promise>; + delete(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<{ deleted: boolean }>; + }; + users: { getById(id: number): Promise }; ws: { broadcastToTrip(tripId: number, event: string, data: Record): Promise; broadcastToUser(userId: number, event: string, data: Record): Promise; @@ -32,6 +93,17 @@ export interface PluginContext { warn(msg: string, meta?: Record): void; error(msg: string, meta?: Record): void; }; + /** Call a function another plugin exposes. `pluginId` must be a declared, version- + * satisfied dependency that lists `fn` in its manifest `capabilities.provides`. The + * call runs as the current acting user. */ + plugins: { + call(pluginId: string, fn: string, args?: unknown): Promise; + }; + /** Publish an event to dependents that subscribed to it. `name` must be declared in + * this plugin's manifest `capabilities.emits`. Fire-and-forget. */ + events: { + emit(name: string, payload?: unknown): void; + }; } export interface PluginRequest { @@ -81,7 +153,40 @@ export interface CalendarEvent { } export interface CalendarSource { getName(): string; - getEvents(userId: number, start: Date, end: Date): Promise; + // start/end are ISO strings — the host->plugin boundary is JSON, so a Date would + // arrive as a string anyway (kept in lockstep with the runtime SDK copy). + getEvents(userId: number, start: string, end: string): Promise; +} +/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */ +export interface PlaceDetailItem { label: string; value?: string; url?: string; } +export interface PlaceDetailProvider { + /** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. */ + getDetails(placeId: number, ctx: PluginContext): Promise; +} +/** 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 { + /** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. */ + getWarnings(tripId: number, ctx: PluginContext): Promise; +} + +/** 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 { + /** A core event name (e.g. `place:created`, `day:updated`, `file:created`) or `*` for all. */ + on: string; + handler(payload: { event: string; tripId: number }, ctx: PluginContext): Promise | void; +} + +/** A function this plugin exposes to its dependents (declared in `capabilities.provides`). */ +export type PluginExport = (args: unknown, ctx: PluginContext) => Promise | unknown; + +/** A subscription to another plugin's event. Authorized by declaring that plugin as a + * `pluginDependency`; 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; } export interface PluginDefinition { @@ -89,7 +194,17 @@ export interface PluginDefinition { onUnload?(ctx: PluginContext): Promise | void; routes?: PluginRoute[]; jobs?: PluginJob[]; - hooks?: { photoProvider?: PhotoProvider; calendarSource?: CalendarSource }; + events?: PluginEventSubscription[]; + hooks?: { + photoProvider?: PhotoProvider; + calendarSource?: CalendarSource; + placeDetailProvider?: PlaceDetailProvider; + warningProvider?: WarningProvider; + }; + /** Functions exposed to dependents (names must match manifest `capabilities.provides`). */ + exports?: Record; + /** Subscriptions to other plugins' events (each `plugin` must be a declared dependency). */ + subscriptions?: PluginSubscription[]; } /** Define a plugin. Gives you types; the returned object is what TREK loads. */ @@ -99,3 +214,7 @@ export function definePlugin(def: PluginDefinition): PluginDefinition { export { validateManifest, type PluginManifest, type ValidationResult } from './manifest.js'; export { createMockHost, type MockHostOptions } from './mock-host.js'; +// The design kit for page/widget UIs: inline these into your client/index.html +// (or drop a `` marker and let `dev`/`pack` expand it) to get the +// native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge. +export { TREK_UI_CSS, TREK_THEME_JS, TREK_UI_MARKER, injectTrekUi } from './ui/kit.js'; diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 1ffa3452d..f931629fc 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -4,15 +4,23 @@ * result (no throw) so the CLI can print every problem at once. */ +import { validRange } from 'semver'; + +export interface PluginDependency { + id: string; + version: string; +} export interface PluginManifest { id: string; name: string; version: string; apiVersion: number; - type: 'integration' | 'page' | 'widget'; + type: 'integration' | 'page' | 'widget' | 'trip-page'; permissions: string[]; egress: string[]; nativeModules?: boolean; + requiredAddons: string[]; + pluginDependencies: PluginDependency[]; } export interface ValidationResult { ok: boolean; @@ -25,14 +33,25 @@ const ID_RE = /^[a-z][a-z0-9-]{2,39}$/; // install loader, so surface it locally too. const RESERVED_IDS = new Set(['registry', 'install', 'rescan']); const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +// Addon-id format (lowercase slug, underscores allowed e.g. `llm_parsing`). +const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/; +// Mirror of the server's ADDON_IDS (server/src/addons.ts). Kept in sync so +// `trek-plugin validate` can WARN on an addon id TREK doesn't know — never a hard +// error (a plugin built for a newer TREK may reference an addon this SDK predates). +export const KNOWN_ADDONS = [ + 'mcp', 'packing', 'budget', 'documents', 'vacay', 'atlas', 'collab', 'journey', 'airtrail', 'llm_parsing', 'collections', +]; // An outbound host: exact hostname (single-label sibling or dotted FQDN) or a // `*.`-wildcard with a multi-label suffix. No `*`, no `*.`, no whole-TLD `*.com`, // no spaces (mirrors the server manifest validator). const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i; -const TYPES = ['integration', 'page', 'widget']; +const TYPES = ['integration', 'page', 'widget', 'trip-page']; const KNOWN_PERMISSIONS = [ - 'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', - 'hook:photo-provider', 'hook:calendar-source', 'http:outbound', + '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', ]; function isKnownPermission(p: string): boolean { @@ -70,10 +89,16 @@ export function validateManifest(raw: unknown): ValidationResult { if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"'); for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`); - const widget = (m.capabilities as { widget?: { slot?: unknown } } | undefined)?.widget; - if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') { - errors.push(`widget slot must be "sidebar" or "hero", got "${String(widget.slot)}"`); + const capabilities = (m.capabilities ?? undefined) as { widget?: { slot?: unknown }; provides?: unknown; emits?: unknown } | undefined; + const widget = capabilities?.widget; + if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero' && widget.slot !== 'place-detail') { + errors.push(`widget slot must be "sidebar", "hero" or "place-detail", got "${String(widget.slot)}"`); } + validateCapabilityNames(capabilities?.provides, 'provides', errors); + validateCapabilityNames(capabilities?.emits, 'emits', errors); + + const requiredAddons = validateRequiredAddons(m.requiredAddons, errors); + const pluginDependencies = validatePluginDependencies(m.pluginDependencies, typeof m.id === 'string' ? m.id : '', errors); if (errors.length) return { ok: false, errors }; return { @@ -88,6 +113,66 @@ export function validateManifest(raw: unknown): ValidationResult { permissions, egress, nativeModules: false, + requiredAddons, + pluginDependencies, }, }; } + +// Export/event names exposed to other plugins (dots allowed for event names). +const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/; + +function validateCapabilityNames(raw: unknown, field: string, errors: string[]): void { + if (raw === undefined) return; + if (!Array.isArray(raw)) { + errors.push(`capabilities.${field} must be an array of names`); + return; + } + for (const v of raw) { + if (typeof v !== 'string' || !CAPABILITY_NAME_RE.test(v)) errors.push(`invalid capabilities.${field} entry "${String(v)}"`); + } +} + +function validateRequiredAddons(raw: unknown, errors: string[]): string[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + errors.push('requiredAddons must be an array of addon ids'); + return []; + } + const out: string[] = []; + for (const v of raw) { + if (typeof v !== 'string' || !ADDON_ID_RE.test(v)) { + errors.push(`invalid requiredAddons entry "${String(v)}"`); + continue; + } + if (!out.includes(v)) out.push(v); + } + return out; +} + +function validatePluginDependencies(raw: unknown, selfId: string, errors: string[]): PluginDependency[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + errors.push('pluginDependencies must be an array of { id, version }'); + return []; + } + const out: PluginDependency[] = []; + for (const v of raw) { + if (!v || typeof v !== 'object') { + errors.push('each pluginDependencies entry must be an object'); + continue; + } + const d = v as Record; + const id = typeof d.id === 'string' ? d.id : ''; + const version = typeof d.version === 'string' ? d.version : ''; + if (!ID_RE.test(id)) errors.push(`invalid pluginDependencies id "${id}"`); + else if (RESERVED_IDS.has(id)) errors.push(`pluginDependencies id "${id}" is reserved`); + else if (id === selfId) errors.push(`plugin "${selfId}" cannot depend on itself`); + else if (out.some((e) => e.id === id)) errors.push(`duplicate pluginDependencies id "${id}"`); + if (!version || validRange(version) === null) errors.push(`invalid pluginDependencies version range "${version}" for "${id || '?'}"`); + if (ID_RE.test(id) && !RESERVED_IDS.has(id) && id !== selfId && version && validRange(version) !== null && !out.some((e) => e.id === id)) { + out.push({ id, version }); + } + } + return out; +} diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index 8c979d6b2..956662684 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -1,4 +1,4 @@ -import type { PluginContext } from './index.js'; +import type { PluginContext, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User } from './index.js'; /** * A mock PluginContext for unit-testing a plugin without a running TREK @@ -12,11 +12,31 @@ import type { PluginContext } from './index.js'; export interface MockHostOptions { grants?: string[]; config?: Record; - /** Fixtures keyed by trip id; `members` gates access like the real host. */ - trips?: Record; + /** + * Fixtures keyed by trip id; `members` gates access like the real host. + * `costs` seeds budget items; `canEditCosts` (default true) models the + * 'budget_edit' permission for `costs.create`. + */ + trips?: Record< + number, + { + members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[]; costs?: unknown[]; + days?: unknown[]; assignments?: unknown[]; packing?: unknown[]; files?: unknown[]; + /** Default true — model the place_edit / day_edit / trip_edit permission for writes. */ + canEditCosts?: boolean; canEditPlaces?: boolean; canEditDays?: boolean; canEditTrip?: boolean; + } + >; users?: Record; /** Optional canned db.query results, keyed by the exact sql string. */ queryResults?: Record; + /** The host-bound acting user for costs.* (a job/onLoad has none → refused). */ + actingUserId?: number; + /** Whether the Costs (budget) addon is enabled; gates all costs.* (default true). */ + budgetAddonEnabled?: boolean; + /** Exports of the plugins this one depends on, keyed by plugin id then fn name. + * `ctx.plugins.call(id, fn, args)` invokes the matching function; a missing entry + * throws RESOURCE_FORBIDDEN (models "not a satisfied dependency / not exported"). */ + pluginExports?: Record unknown>>; } export interface MockHost { @@ -25,6 +45,8 @@ export interface MockHost { calls: { method: string; args: unknown[] }[]; logs: { level: string; msg: string }[]; broadcasts: { kind: 'trip' | 'user'; target: number; event: string; data: unknown }[]; + /** Events the plugin published via ctx.events.emit, for assertions. */ + emitted: { name: string; payload: unknown }[]; } class PermissionDenied extends Error {} @@ -34,6 +56,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { const calls: MockHost['calls'] = []; const logs: MockHost['logs'] = []; const broadcasts: MockHost['broadcasts'] = []; + const emitted: MockHost['emitted'] = []; const need = (perm: string, method: string) => { calls.push({ method, args: [] }); @@ -44,6 +67,30 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { if (!t || !t.members.includes(asUserId)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${tripId}`); return t; }; + const requireBudgetAddon = () => { + if (opts.budgetAddonEnabled === false) throw new Error('RESOURCE_FORBIDDEN: the costs addon is disabled'); + }; + const requireActingUser = (): number => { + if (opts.actingUserId === undefined) throw new Error('RESOURCE_FORBIDDEN: this call requires an authenticated user context'); + return opts.actingUserId; + }; + const assertEdit = ( + t: { canEditPlaces?: boolean; canEditDays?: boolean; canEditTrip?: boolean }, + flag: 'canEditPlaces' | 'canEditDays' | 'canEditTrip', + tripId: number, + ) => { + if (t[flag] === false) throw new Error(`RESOURCE_FORBIDDEN: no permission to edit trip ${tripId}`); + }; + const rows = (arr: unknown[] | undefined): Array> => (arr ?? []) as Array>; + // In-memory namespaced metadata store for ctx.meta (per mock plugin). + const metaStore: Record = {}; + const metaKey = (et: string, eid: number, key: string) => `${et}:${eid}:${key}`; + const metaGate = (entityType: string, entityId: number) => { + // The real host resolves place/day → trip; the mock only membership-checks the + // 'trip' entity type and otherwise just requires an acting user. + if (entityType === 'trip') assertMember(entityId, requireActingUser()); + else requireActingUser(); + }; const ctx: PluginContext = { id: 'mock-plugin', @@ -65,21 +112,199 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { trips: { async getById(tripId, asUserId) { need('db:read:trips', 'trips.getById'); - return assertMember(tripId, asUserId).data ?? null; + return (assertMember(tripId, asUserId ?? requireActingUser()).data ?? null) as Trip | null; }, async getPlaces(tripId, asUserId) { need('db:read:trips', 'trips.getPlaces'); - return assertMember(tripId, asUserId).places ?? []; + return (assertMember(tripId, asUserId ?? requireActingUser()).places ?? []) as Place[]; }, async getReservations(tripId, asUserId) { need('db:read:trips', 'trips.getReservations'); - return assertMember(tripId, asUserId).reservations ?? []; + return (assertMember(tripId, asUserId ?? requireActingUser()).reservations ?? []) as Reservation[]; + }, + async update(tripId, input) { + need('db:write:trips', 'trips.update'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditTrip', tripId); + const data = (t.data ??= {}) as Record; + Object.assign(data, input); + return data as Trip; + }, + }, + packing: { + async list(tripId) { + need('db:read:packing', 'packing.list'); + return (assertMember(tripId, requireActingUser()).packing ?? []) as PackingItem[]; + }, + }, + files: { + async list(tripId) { + need('db:read:files', 'files.list'); + return (assertMember(tripId, requireActingUser()).files ?? []) as TripFile[]; + }, + }, + costs: { + async getByTrip(tripId) { + need('db:read:costs', 'costs.getByTrip'); + requireBudgetAddon(); + return (assertMember(tripId, requireActingUser()).costs ?? []) as BudgetItem[]; + }, + async listMine() { + need('db:read:costs', 'costs.listMine'); + requireBudgetAddon(); + const uid = requireActingUser(); + return Object.values(opts.trips ?? {}) + .filter((t) => t.members.includes(uid)) + .flatMap((t) => t.costs ?? []) as BudgetItem[]; + }, + async create(tripId, input) { + need('db:write:costs', 'costs.create'); + requireBudgetAddon(); + const t = assertMember(tripId, requireActingUser()); + if (t.canEditCosts === false) { + throw new Error(`RESOURCE_FORBIDDEN: no permission to edit costs on trip ${tripId}`); + } + const item = { id: (t.costs?.length ?? 0) + 1, trip_id: tripId, ...input }; + (t.costs ??= []).push(item); + return item; + }, + async update(tripId, itemId, input) { + need('db:write:costs', 'costs.update'); + requireBudgetAddon(); + const t = assertMember(tripId, requireActingUser()); + if (t.canEditCosts === false) { + throw new Error(`RESOURCE_FORBIDDEN: no permission to edit costs on trip ${tripId}`); + } + const item = rows(t.costs).find((x) => x.id === itemId); + if (!item) throw new Error(`RESOURCE_FORBIDDEN: no cost ${itemId} on trip ${tripId}`); + Object.assign(item, input); + return item as BudgetItem; + }, + async delete(tripId, itemId) { + need('db:write:costs', 'costs.delete'); + requireBudgetAddon(); + const t = assertMember(tripId, requireActingUser()); + if (t.canEditCosts === false) { + throw new Error(`RESOURCE_FORBIDDEN: no permission to edit costs on trip ${tripId}`); + } + const list = rows((t.costs ??= [])); + const i = list.findIndex((x) => x.id === itemId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no cost ${itemId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + places: { + async create(tripId, input) { + need('db:write:places', 'places.create'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditPlaces', tripId); + const place = { id: (t.places?.length ?? 0) + 1, trip_id: tripId, ...input }; + (t.places ??= []).push(place); + return place; + }, + async update(tripId, placeId, input) { + need('db:write:places', 'places.update'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditPlaces', tripId); + const place = rows(t.places).find((x) => x.id === placeId); + if (!place) throw new Error(`RESOURCE_FORBIDDEN: no place ${placeId} on trip ${tripId}`); + Object.assign(place, input); + return place as Place; + }, + async delete(tripId, placeId) { + need('db:write:places', 'places.delete'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditPlaces', tripId); + const list = rows((t.places ??= [])); + const i = list.findIndex((x) => x.id === placeId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no place ${placeId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + days: { + async create(tripId, input) { + need('db:write:days', 'days.create'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const day = { id: (t.days?.length ?? 0) + 1, trip_id: tripId, ...input }; + (t.days ??= []).push(day); + return day; + }, + async update(tripId, dayId, input) { + need('db:write:days', 'days.update'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const day = rows(t.days).find((x) => x.id === dayId); + if (!day) throw new Error(`RESOURCE_FORBIDDEN: no day ${dayId} on trip ${tripId}`); + Object.assign(day, input); + return day as Day; + }, + async delete(tripId, dayId) { + need('db:write:days', 'days.delete'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const list = rows((t.days ??= [])); + const i = list.findIndex((x) => x.id === dayId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no day ${dayId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + itinerary: { + async assign(tripId, dayId, placeId, notes) { + need('db:write:itinerary', 'itinerary.assign'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const assignment = { id: (t.assignments?.length ?? 0) + 1, day_id: dayId, place_id: placeId, notes: notes ?? null }; + (t.assignments ??= []).push(assignment); + return assignment; + }, + async unassign(tripId, assignmentId) { + need('db:write:itinerary', 'itinerary.unassign'); + const t = assertMember(tripId, requireActingUser()); + assertEdit(t, 'canEditDays', tripId); + const list = rows((t.assignments ??= [])); + const i = list.findIndex((x) => x.id === assignmentId); + if (i < 0) throw new Error(`RESOURCE_FORBIDDEN: no assignment ${assignmentId} on trip ${tripId}`); + list.splice(i, 1); + return { deleted: true }; + }, + }, + meta: { + async get(entityType, entityId, key) { + need('db:meta', 'meta.get'); + metaGate(entityType, entityId); + return metaStore[metaKey(entityType, entityId, key)] ?? null; + }, + async set(entityType, entityId, key, value) { + need('db:meta', 'meta.set'); + metaGate(entityType, entityId); + metaStore[metaKey(entityType, entityId, key)] = value ?? null; + return { key, value: value ?? null }; + }, + async list(entityType, entityId) { + need('db:meta', 'meta.list'); + metaGate(entityType, entityId); + const prefix = `${entityType}:${entityId}:`; + const out: Record = {}; + for (const k of Object.keys(metaStore)) if (k.startsWith(prefix)) out[k.slice(prefix.length)] = metaStore[k]; + return out; + }, + async delete(entityType, entityId, key) { + need('db:meta', 'meta.delete'); + metaGate(entityType, entityId); + const k = metaKey(entityType, entityId, key); + const had = k in metaStore; + delete metaStore[k]; + return { deleted: had }; }, }, users: { async getById(id) { need('db:read:users', 'users.getById'); - return opts.users?.[id] ?? null; + return (opts.users?.[id] ?? null) as User | null; }, }, ws: { @@ -97,7 +322,21 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { warn: (msg) => logs.push({ level: 'warn', msg }), error: (msg) => logs.push({ level: 'error', msg }), }, + plugins: { + async call(pluginId, fn, args) { + calls.push({ method: 'plugins.call', args: [pluginId, fn, args] }); + const impl = opts.pluginExports?.[pluginId]?.[fn]; + if (typeof impl !== 'function') throw new Error(`RESOURCE_FORBIDDEN: plugin ${pluginId} does not export "${fn}"`); + return impl(args); + }, + }, + events: { + emit(name, payload) { + calls.push({ method: 'events.emit', args: [name, payload] }); + emitted.push({ name, payload }); + }, + }, }; - return { ctx, calls, logs, broadcasts }; + return { ctx, calls, logs, broadcasts, emitted }; } diff --git a/plugin-sdk/src/ui/kit.ts b/plugin-sdk/src/ui/kit.ts new file mode 100644 index 000000000..eff2eae83 --- /dev/null +++ b/plugin-sdk/src/ui/kit.ts @@ -0,0 +1,335 @@ +/** + * The TREK plugin design kit (#plugins). + * + * A plugin's UI runs in a sandboxed, opaque-origin iframe — it can't load TREK's + * stylesheet, only postMessage. So instead of forcing every author to re-derive the + * look, we ship it: a token-driven stylesheet (`TREK_UI_CSS`) plus a tiny bootstrap + * (`TREK_THEME_JS`) that wires the frame to the host. Both are plain strings, meant + * to be INLINED into the plugin's own `client/index.html` (the CSP forbids external + * /` (typically via + * the `` marker). It: announces readiness; applies the host's theme + * tokens, theme name and appearance flags to the document; auto-reports its height so + * a widget/page self-sizes; and installs a small `window.trek` helper over the same + * bridge messages the host already understands — it adds no new capability. + */ +export const TREK_THEME_JS = `(function () { + 'use strict'; + var docEl = document.documentElement; + var ctxHandlers = []; + var lastCtx = null; + var pending = {}; + var seq = 0; + var lastH = -1; + + function send(msg) { try { window.parent.postMessage(msg, '*'); } catch (e) {} } + function setFlag(name, on) { if (on) { docEl.setAttribute(name, ''); } else { docEl.removeAttribute(name); } } + + function applyContext(m) { + if (m.theme) { docEl.setAttribute('data-theme', m.theme); } + var t = m.tokens || {}; + for (var k in t) { + if (Object.prototype.hasOwnProperty.call(t, k) && t[k]) { docEl.style.setProperty(k, t[k]); } + } + var a = m.appearance || {}; + setFlag('data-reduce-motion', a.reducedMotion); + setFlag('data-no-transparency', a.noTransparency); + if (a.density) { docEl.setAttribute('data-density', a.density); } + if (a.scheme) { docEl.setAttribute('data-scheme', a.scheme); } + if (document.body) { document.body.classList.add('trek-ui'); } + } + + function reportHeight() { + var h = Math.ceil(document.documentElement.scrollHeight); + if (h > 0 && h !== lastH) { lastH = h; send({ type: 'trek:resize', height: h }); } + } + + window.addEventListener('message', function (ev) { + // Opaque frame: origin serialises to 'null', so trust the SENDER — only our real + // parent window. Never act on a claimed id or on origin. + if (ev.source !== window.parent) { return; } + var m = ev.data; + if (!m || typeof m !== 'object') { return; } + if (m.type === 'trek:context') { + lastCtx = m; api.context = m; + applyContext(m); + for (var i = 0; i < ctxHandlers.length; i++) { try { ctxHandlers[i](m); } catch (e) {} } + reportHeight(); + } else if (m.type === 'trek:response') { + var p = pending[m.requestId]; + if (p) { delete pending[m.requestId]; p.resolve(m.data); } + } else if (m.type === 'trek:error') { + var q = pending[m.requestId]; + if (q) { delete pending[m.requestId]; var err = new Error(m.message || 'invoke failed'); err.code = m.code; q.reject(err); } + } + }); + + // Native DOM helpers so a widget can build kit-styled UI with no bundler and no + // hand-written CSS — every element carries the same trek-* classes the kit ships. + function mkEl(tag, props, children) { + var node = document.createElement(tag); + props = props || {}; + for (var k in props) { + if (!Object.prototype.hasOwnProperty.call(props, k)) { continue; } + var v = props[k]; + if (v == null) { continue; } + if (k === 'class' || k === 'className') { node.className = v; } + else if (k === 'text') { node.textContent = v; } + else if (k === 'html') { node.innerHTML = v; } + else if (k === 'on') { for (var ev in v) { if (Object.prototype.hasOwnProperty.call(v, ev)) { node.addEventListener(ev, v[ev]); } } } + else { node.setAttribute(k, v); } + } + var kids = children == null ? [] : (typeof children === 'string' || children.nodeType ? [children] : children); + for (var i = 0; i < kids.length; i++) { + var c = kids[i]; + if (c == null) { continue; } + node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return node; + } + var ui = { + el: mkEl, + button: function (label, opts) { + opts = opts || {}; + return mkEl('button', { class: 'trek-btn' + (opts.variant ? ' trek-btn--' + opts.variant : ''), type: 'button', text: label, on: opts.onClick ? { click: opts.onClick } : null }, null); + }, + card: function (children) { return mkEl('div', { class: 'trek-card' }, children); }, + chip: function (text, variant) { return mkEl('span', { class: 'trek-chip' + (variant ? ' trek-chip--' + variant : ''), text: text }, null); }, + input: function (opts) { opts = opts || {}; return mkEl('input', { class: 'trek-input', type: opts.type || 'text', placeholder: opts.placeholder || '', value: opts.value || '' }, null); }, + mount: function (node, target) { (target || document.body).appendChild(node); return node; } + }; + + var api = { + context: null, + ui: ui, + ready: function () { send({ type: 'trek:ready' }); }, + requestContext: function () { send({ type: 'trek:context:request' }); }, + onContext: function (cb) { + ctxHandlers.push(cb); + if (lastCtx) { try { cb(lastCtx); } catch (e) {} } + return function () { var i = ctxHandlers.indexOf(cb); if (i >= 0) { ctxHandlers.splice(i, 1); } }; + }, + notify: function (level, message) { send({ type: 'trek:notify', level: level, message: message }); }, + navigate: function (to) { send({ type: 'trek:navigate', to: to }); }, + resize: function (px) { var h = px | 0; if (h > 0) { lastH = h; send({ type: 'trek:resize', height: h }); } }, + invoke: function (sub, opts) { + opts = opts || {}; + var id = 'r' + (++seq); + return new Promise(function (resolve, reject) { + pending[id] = { resolve: resolve, reject: reject }; + send({ type: 'trek:invoke', requestId: id, sub: sub, method: opts.method, body: opts.body }); + }); + } + }; + window.trek = api; + + function boot() { + if (document.body) { document.body.classList.add('trek-ui'); } + api.ready(); + reportHeight(); + if (typeof ResizeObserver !== 'undefined' && document.body) { + new ResizeObserver(reportHeight).observe(document.body); + } + } + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } +})();`; + +/** + * Replace the `` marker in a plugin's HTML with the inlined kit + * (style + bootstrap). A no-op when the marker is absent, so it is safe to run over + * any HTML. The source file on disk is never touched — the expansion happens at + * dev-serve / pack time, so an author's `client/index.html` stays a one-line opt-in. + */ +export function injectTrekUi(html: string): string { + if (!html.includes(TREK_UI_MARKER)) return html; + const block = `\n`; + return html.split(TREK_UI_MARKER).join(block); +} diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts new file mode 100644 index 000000000..6e6728db9 --- /dev/null +++ b/plugin-sdk/test/cli.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { validateManifest } from '../src/index.js'; +import { scaffold } from '../src/cli/create.js'; +import { PERMISSION_CATALOG, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js'; +import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js'; + +describe('scaffold egress (http:outbound)', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'egress-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('writes egress so an http:outbound plugin validates', () => { + scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'], egress: ['api.example.com'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8')); + expect(m.egress).toEqual(['api.example.com']); + expect(validateManifest(m).ok).toBe(true); + }); + + it('without egress the same manifest is invalid — which the wizard now prevents', () => { + scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8')); + expect(m.egress).toBeUndefined(); + expect(validateManifest(m).ok).toBe(false); // http:outbound requires an egress allow-list + }); + + it('omits egress entirely when none is given (no empty array noise)', () => { + scaffold('plain-plug', 'integration', tmp, { permissions: ['db:own'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'plain-plug', 'trek-plugin.json'), 'utf8')); + expect('egress' in m).toBe(false); + }); +}); + +describe('permission catalog', () => { + it('covers exactly the known permission ids (none lost in the move to ui.ts)', () => { + expect(KNOWN_PERMISSIONS).toEqual([ + 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', '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', 'http:outbound', + ]); + for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described + }); +}); + +describe('resolveMenuChoice', () => { + it('maps every menu value (commands + control entries) to itself', () => { + for (const item of [...PRIMARY_MENU, ...ADVANCED_MENU]) { + expect(resolveMenuChoice(item.value)).toBe(item.value); + } + }); + it('returns undefined for anything not in the menu', () => { + expect(resolveMenuChoice('nope')).toBeUndefined(); + expect(resolveMenuChoice('')).toBeUndefined(); + }); +}); + +describe('missingArgs', () => { + it('reports the absent required flags, in order', () => { + expect(missingArgs({}, ['repo', 'tag'])).toEqual(['repo', 'tag']); + expect(missingArgs({ repo: 'a/b' }, ['repo', 'tag'])).toEqual(['tag']); + expect(missingArgs({ repo: 'a/b', tag: 'v1.0.0' }, ['repo', 'tag'])).toEqual([]); + }); +}); + +describe('isInteractive', () => { + it('is false when stdin/stdout are not TTYs (CI / pipes — the parity path)', () => { + const inTTY = process.stdin.isTTY; + const outTTY = process.stdout.isTTY; + try { + (process.stdin as { isTTY?: boolean }).isTTY = undefined; + (process.stdout as { isTTY?: boolean }).isTTY = undefined; + expect(isInteractive()).toBe(false); + (process.stdin as { isTTY?: boolean }).isTTY = true; + (process.stdout as { isTTY?: boolean }).isTTY = true; + expect(isInteractive()).toBe(true); + } finally { + (process.stdin as { isTTY?: boolean }).isTTY = inTTY; + (process.stdout as { isTTY?: boolean }).isTTY = outTTY; + } + }); +}); + +describe('scaffold + validate dependencies', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('scaffolds empty dependency arrays that validate', () => { + scaffold('dep-plug', 'integration', tmp, { permissions: ['db:own'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'dep-plug', 'trek-plugin.json'), 'utf8')); + expect(m.requiredAddons).toEqual([]); + expect(m.pluginDependencies).toEqual([]); + expect(validateManifest(m).ok).toBe(true); + }); + + it('scaffolds requiredAddons passed as an option', () => { + scaffold('addon-plug', 'integration', tmp, { permissions: ['db:own'], requiredAddons: ['budget'] }); + const m = JSON.parse(fs.readFileSync(path.join(tmp, 'addon-plug', 'trek-plugin.json'), 'utf8')); + expect(m.requiredAddons).toEqual(['budget']); + expect(validateManifest(m).ok).toBe(true); + }); +}); + +describe('validateManifest dependency rules', () => { + const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] }; + it('accepts valid requiredAddons + pluginDependencies', () => { + const r = validateManifest({ ...base, requiredAddons: ['budget', 'journey'], pluginDependencies: [{ id: 'koffi', version: '>=1.0.0 <2.0.0' }] }); + expect(r.ok).toBe(true); + expect(r.manifest?.requiredAddons).toEqual(['budget', 'journey']); + expect(r.manifest?.pluginDependencies).toEqual([{ id: 'koffi', version: '>=1.0.0 <2.0.0' }]); + }); + it('rejects a bad addon id, bad dep range, self-dependency, and duplicates', () => { + expect(validateManifest({ ...base, requiredAddons: ['Nope!'] }).ok).toBe(false); + expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: 'nope' }] }).ok).toBe(false); + expect(validateManifest({ ...base, pluginDependencies: [{ id: 'my-plug', version: '*' }] }).ok).toBe(false); + expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] }).ok).toBe(false); + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index eadf27fed..1e6978dd7 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -87,6 +87,81 @@ describe('createMockHost', () => { ctx.log.info('hi'); expect(logs).toEqual([{ level: 'info', msg: 'hi' }]); }); + + it('gates costs reads/writes on the grant, addon, membership and edit permission', async () => { + const { ctx } = createMockHost({ + grants: ['db:read:costs', 'db:write:costs'], + actingUserId: 42, + trips: { + 1: { members: [42], costs: [{ id: 5, name: 'Hotel' }] }, + 2: { members: [42], costs: [{ id: 6, name: 'Food' }], canEditCosts: false }, + }, + }); + // read: trip-scoped + cross-trip aggregate + expect(await ctx.costs.getByTrip(1)).toEqual([{ id: 5, name: 'Hotel' }]); + expect(await ctx.costs.listMine()).toEqual([{ id: 5, name: 'Hotel' }, { id: 6, name: 'Food' }]); + // write: allowed where the user may edit, refused where they may not + expect(await ctx.costs.create(1, { name: 'Taxi' })).toMatchObject({ trip_id: 1, name: 'Taxi' }); + await expect(ctx.costs.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + // update: edits an existing item where allowed, refused without edit permission + expect(await ctx.costs.update(1, 5, { name: 'Hostel' })).toMatchObject({ id: 5, name: 'Hostel' }); + await expect(ctx.costs.update(2, 6, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + // update: a missing item is refused + await expect(ctx.costs.update(1, 999, { name: 'X' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + // delete: removes where allowed, refused without edit permission / when missing + expect(await ctx.costs.delete(1, 5)).toEqual({ deleted: true }); + await expect(ctx.costs.delete(2, 6)).rejects.toThrow(/RESOURCE_FORBIDDEN/); + await expect(ctx.costs.delete(1, 999)).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('gates planner writes (places/days/itinerary/trips) on grant, membership and edit permission', async () => { + const { ctx } = createMockHost({ + grants: ['db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips'], + actingUserId: 42, + trips: { + 1: { members: [42], data: { id: 1, title: 'Japan' } }, + 2: { members: [42], canEditPlaces: false }, + }, + }); + // create + assign on a trip the user may edit + const place = await ctx.places.create(1, { name: 'Fushimi Inari' }); + expect(place).toMatchObject({ trip_id: 1, name: 'Fushimi Inari' }); + const day = await ctx.days.create(1, { notes: 'Day 1' }); + expect(await ctx.itinerary.assign(1, (day as { id: number }).id, (place as { id: number }).id)) + .toMatchObject({ day_id: (day as { id: number }).id }); + expect(await ctx.trips.update(1, { title: 'Renamed' })).toMatchObject({ title: 'Renamed' }); + // refused where the user may not edit + await expect(ctx.places.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('refuses a planner write without the matching write scope', async () => { + const { ctx } = createMockHost({ grants: ['db:read:trips'], actingUserId: 42, trips: { 1: { members: [42] } } }); + await expect(ctx.places.create(1, { name: 'X' })).rejects.toThrow(/PERMISSION_DENIED/); + }); + + it('ctx.meta stores/reads/lists/deletes namespaced metadata, gated on db:meta', async () => { + const { ctx } = createMockHost({ grants: ['db:meta'], actingUserId: 42, trips: { 1: { members: [42] } } }); + await ctx.meta.set('trip', 1, 'rating', 5); + expect(await ctx.meta.get('trip', 1, 'rating')).toBe(5); + expect(await ctx.meta.list('trip', 1)).toEqual({ rating: 5 }); + expect(await ctx.meta.delete('trip', 1, 'rating')).toEqual({ deleted: true }); + expect(await ctx.meta.get('trip', 1, 'rating')).toBe(null); + const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } }); + await expect(ungranted.ctx.meta.get('trip', 1, 'x')).rejects.toThrow(/PERMISSION_DENIED/); + }); + + it('refuses costs when the permission is missing or the addon is disabled', async () => { + const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } }); + await expect(ungranted.ctx.costs.getByTrip(1)).rejects.toThrow(/PERMISSION_DENIED/); + + const addonOff = createMockHost({ + grants: ['db:read:costs'], + actingUserId: 42, + budgetAddonEnabled: false, + trips: { 1: { members: [42], costs: [] } }, + }); + await expect(addonOff.ctx.costs.getByTrip(1)).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); }); describe('scaffold + validate CLIs', () => { @@ -106,6 +181,33 @@ describe('scaffold + validate CLIs', () => { expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template }); + it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => { + scaffold('kit-plug', 'widget', tmp); + const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8'); + expect(html).toContain(''); // the one-line opt-in + expect(html).toContain('trek.onContext'); // uses the bridge the marker installs + expect(html).not.toContain('--glass-bg'); // kit is NOT pre-inlined in the source + }); + + it('scaffolds a trip-page plugin (a tab inside the trip) with a client UI', () => { + scaffold('trip-diary', 'trip-page', tmp); + const dir = path.join(tmp, 'trip-diary'); + const m = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')); + expect(m.type).toBe('trip-page'); + expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true); // non-integration → gets a UI + expect(validatePluginDir(dir).ok).toBe(true); + }); + + it('pack expands the design-kit marker into the archived client HTML', () => { + scaffold('kit-plug', 'widget', tmp); + const out = path.join(tmp, 'kit-plug.zip'); + packPluginDir(path.join(tmp, 'kit-plug'), out); + const html = readZip(fs.readFileSync(out))['client/index.html'].toString('utf8'); + expect(html).not.toContain(''); // expanded away + expect(html).toContain('.trek-glass'); // kit CSS inlined + expect(html).toContain('window.trek'); // bridge inlined + }); + it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => { scaffold('my-widget', 'widget', tmp); const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8')); @@ -344,3 +446,38 @@ describe('sign + keygen (author signatures, TOFU)', () => { expect(() => buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/); }); }); + +describe('mock-host inter-plugin (plugins.call + events.emit)', () => { + it('calls a configured dependency export and records the call', async () => { + const { ctx, calls } = createMockHost({ + pluginExports: { 'dep-lib': { greet: (args: any) => ({ hi: args?.who }) } }, + }); + const r = await ctx.plugins.call('dep-lib', 'greet', { who: 'ada' }); + expect(r).toEqual({ hi: 'ada' }); + expect(calls.some((c) => c.method === 'plugins.call')).toBe(true); + }); + + it('throws RESOURCE_FORBIDDEN calling an export that is not configured', async () => { + const { ctx } = createMockHost({ pluginExports: { 'dep-lib': {} } }); + await expect(ctx.plugins.call('dep-lib', 'nope', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/); + await expect(ctx.plugins.call('other', 'greet', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('records emitted events', () => { + const { ctx, emitted } = createMockHost({}); + ctx.events.emit('rate.updated', { pair: 'USD/EUR' }); + expect(emitted).toEqual([{ name: 'rate.updated', payload: { pair: 'USD/EUR' } }]); + }); +}); + +describe('validateManifest capabilities.provides/emits', () => { + const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] }; + it('accepts well-formed provides + emits', () => { + const r = validateManifest({ ...base, capabilities: { provides: ['computeRate'], emits: ['rate.updated'] } }); + expect(r.ok).toBe(true); + }); + it('rejects a malformed export/event name and a non-array', () => { + expect(validateManifest({ ...base, capabilities: { provides: ['bad name!'] } }).ok).toBe(false); + expect(validateManifest({ ...base, capabilities: { emits: 'nope' } }).ok).toBe(false); + }); +}); diff --git a/plugin-sdk/test/ui-kit.test.ts b/plugin-sdk/test/ui-kit.test.ts new file mode 100644 index 000000000..a3bd09bf6 --- /dev/null +++ b/plugin-sdk/test/ui-kit.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { TREK_UI_CSS, TREK_THEME_JS, TREK_UI_MARKER, injectTrekUi } from '../src/index.js'; + +describe('design kit', () => { + it('ships a token-driven stylesheet with the signature TREK components', () => { + // Components an author leans on. + for (const cls of ['.trek-card', '.trek-glass', '.trek-btn', '.trek-input', '.trek-chip', '.trek-row']) { + expect(TREK_UI_CSS).toContain(cls); + } + // The glass recipe is baked (it can't be read over the bridge) and swaps for dark. + expect(TREK_UI_CSS).toContain('--glass-bg'); + expect(TREK_UI_CSS).toContain('--glass-highlight'); + expect(TREK_UI_CSS).toContain('[data-theme="dark"]'); + // Honours the same accessibility choices as the host. + expect(TREK_UI_CSS).toContain('prefers-reduced-motion'); + expect(TREK_UI_CSS).toContain('[data-no-transparency]'); + }); + + it('ships a bootstrap that wires the frame and never breaks inline embedding', () => { + expect(TREK_THEME_JS).toContain("type: 'trek:ready'"); + expect(TREK_THEME_JS).toContain('window.trek'); + expect(TREK_THEME_JS).toContain("'trek:context'"); + // Applies the host tokens + theme to the document. + expect(TREK_THEME_JS).toContain('setProperty'); + expect(TREK_THEME_JS).toContain("setAttribute('data-theme'"); + // Auto-sizing so a widget/page reports its own height. + expect(TREK_THEME_JS).toContain('trek:resize'); + // Trusts only the real parent window (opaque frame has a 'null' origin). + expect(TREK_THEME_JS).toContain('ev.source !== window.parent'); + }); + + it('never contains a closing tag that would break