* fix(plugins): prevent arbitrary api access

* fix(planner): disable drag & drop on mobile so the places list scrolls (#1432)

On touch devices the draggable rows hijack the scroll gesture, so dragging
to scroll started an HTML5 drag and popped up the file-import overlay instead
of scrolling. Gate the draggable rows and the sidebar file-drop handlers on
!isMobile across the places sidebar and the day plan (places, transports,
notes), and hide the grip handle — the arrow reorder buttons take over there.

* fix(inspector): make the remove-from-day button icon-only on mobile

* fix(collections): show the save picker above the mobile place detail

* fix(plugins): seal IPC parent/child for good

* test(inspector): match the icon-only remove-from-day button

* feat(sdk): switch plain ts for clack/prompts interactive session

* feat(plugins): force-refresh the registry from the rescan button

The registry is cached for 30 min server-side and GitHub serves it with a
5-min CDN cache, so a freshly published plugin could take up to ~35 min to
appear. The rescan/reload button now force-pulls the registry: it bypasses the
in-memory cache and appends a cache-buster + no-cache headers to beat the CDN,
and refreshes the browse grid immediately.

* feat(sdk): bump plugin version

* fix(stored settings): prevent local storage drop when update not successful

* feat(plugins): sideload plugins by uploading a .zip

Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for
installing a plugin archive directly — handy for testing a build before it goes
to the registry. It reuses the registry install pipeline (slip/bomb-safe
extract, strict manifest validation, native-binary scan) via a new
POST /admin/plugins/upload, and only skips the registry sha256/signature checks
that a sideload can't have.

Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no
GitHub link, no auto-update) and always land INACTIVE — replacing a running or
active plugin stops it and clears the active flag first, so new code never runs
without a fresh activation + permission consent.

* fix(planner): keep the day-plan collapse state after fully closing the page

The expanded/collapsed days were stored in sessionStorage, which survives a
reload but is wiped when the tab or window is closed — so every fresh open
re-expanded all days, which is tedious to re-collapse on long (10+ day) trips.
Store it in localStorage instead so a collapsed layout sticks until it's
changed.

* fix(i18n): correct Vietnamese translation of 'Disabled' (#1438)

'Tàn tật' means physically handicapped/disabled-person, not the
off/disabled state of a toggle. Replace with 'Tắt' (off), matching
the existing 'admin.plugins.stateOff' translation.

Affects admin.notifications.none and admin.addons.disabled.

* add the code of conduct

* fix(plugins): let widgets follow the in-app dark-mode toggle

The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we
only sent the context — including the theme — once, on trek:ready. So toggling
dark mode in TREK left already-mounted widgets on the old theme until a reload.
Watch the <html> `dark` class and re-post the context when it flips; plugins
already re-apply the theme on trek:context.

* fix(plugins): deliver widget context on load so the theme is right on first paint

* fix(plugins): give widget cards the native glassy look and auto-height

Widget plugins rendered in a plain card with a fixed 180px body, so they looked
foreign next to the glassy dashboard tools and taller widgets had their controls
clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase
title) and let the body grow to the height the widget reports over trek:resize.

* feat(plugins): add read/rwite costs

* feat(plugin): better readme/index.js

* feat(plugin): better readme/index.js

* feat(plugins): hand widgets TREK's theme tokens, formats and display identity

Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design
tokens for the current theme), `formats` (currency/date/units/timezone) and a
`user` display object (name/avatar/isAdmin — never the email, role only as a
boolean). Re-sent on every theme toggle. A widget can now apply the tokens and
match the host exactly, in both themes and under a custom appearance, instead of
hard-coding a palette that drifts — so plugins feel native, not bolted-on.

* feat(plugins): hand plugins the full palette and appearance state

The theme context only carried a ~19-token subset read off <html> and only
followed the dark-mode toggle. Widen it to the whole global (:root/.dark)
palette — surfaces, text, borders, the accent family, semantic + soft fills,
shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme,
custom accent and high-contrast live, not just light/dark. Also send an
`appearance` block (scheme, density, reduced-motion, no-transparency) mirrored
from the attributes applyAppearance writes on <html>, and re-post the context
whenever any of those actually change (a small signature dedupes unrelated
mutations) so plugins restyle in step with the app.

* feat(plugins): ship a design kit so plugin UIs look native

A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's
stylesheet — so authors had to re-derive the whole look by hand, and most
didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons,
inputs, chips, rows) that consumes the tokens the host already sends and swaps
light/dark, plus a small bootstrap that applies those tokens, mirrors the
appearance flags, auto-reports the frame height and exposes a `window.trek`
helper over the existing bridge. Both are plain strings meant to be inlined
(the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a
`<!-- trek:ui -->` marker. No new capability — only a native look.

* feat(plugins): deliver the design kit — native scaffold + inline on dev/pack

A new page/widget scaffolds a native, glassy starter that talks over
window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it
serves /ui) and `pack` (as the file enters the archive) expand it into the
inlined kit — so the file stays a one-line opt-in and a rebuild always ships
the current kit. Existing plugins opt in the same way, by dropping the marker.

* feat(plugins): faithful themed host preview in dev

`dev` served the plugin UI raw at /ui — top-level, with no host — so the theme,
context and bridge never fired and authors couldn't see the design kit render.
Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's
isolation) and plays the host — posts trek:context with a theme/accent/appearance
toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces
resize/notify/navigate. /ui stays as the raw doc for debugging.

* docs(plugins): document the design kit, window.trek and the token contract

Rewrite the client section of the Plugin Development wiki kit-first: the
`<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the
`/preview` host preview, the full `trek:context` payload (now the whole palette
plus an `appearance` block) and how to apply tokens by hand. Add a "Build a
native UI" section + the new exports to the SDK README.

* feat(budget): add 'Outstanding amount' card

* fix(translations): finish translating new keys

* feat(plugins): trip-page plugins — a plugin tab inside every trip

Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the
trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with
no dashboard nav entry. This is the most-asked planner-extension request from
discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget
planner). It reuses PluginFrame and the existing tab system — the frame already
receives the current tripId over trek:context — so there is no bridge or
security change: only the manifest type enum (server + SDK), the client feed
classification (pluginStore.tripPages), and one render branch in the planner.
The SDK scaffolds it with `create --type trip-page`.

* fix(apple wallet): support for .pkpasses

* feat(plugins): permission-gated write APIs for the planner (#1429)

Plugins can now WRITE core planner data, not just read it, through curated,
membership-checked methods — so downstream features can live in plugins instead
of long-lived core patches. Four new scopes: db:write:places (create/update/delete
places), db:write:days (days), db:write:itinerary (assign/unassign a place on a
day) and db:write:trips (update trip fields).

Each ctx method mirrors costs.create: it validates the input against the SAME
@trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad
has none, so its writes are refused), checks trip access AND the app's edit
permission (place_edit / day_edit / trip_edit), delegates to the real services,
broadcasts the same events so open sessions update live, and records the write in
the tamper-evident capability audit. No new route, no sandbox or CSP change — the
isolation boundary is unchanged; a plugin can only change what its user could change
by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host,
and the wikis are updated.

* docs(plugin): ensure wiki correctness

* feat(plugins): plugin metadata on core entities — db:meta (#1429)

Plugins can now attach their OWN namespaced key/value data to a trip, place or
day without forking the core schema (#1429, request 2). New `db:meta` scope +
`ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table
(migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only
ever sees its own rows. Every call is membership-checked: the entity must belong to
a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB
per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and
recorded in the capability audit. SDK types + mock host, a consent chip + labels in
all 22 locales, and the wikis. No new route, no sandbox change.

* feat(plugins): place-detail plugin slot in the trip planner (#1429)

A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount
its sandboxed frame inside the trip planner's place-detail panel, scoped to the
open place — the frame receives the `placeId` in trek:context alongside the tripId.
This is the UI half of the place-detail-providers ask (reviews/ratings/popular
times shown on a place). It reuses the existing widget mechanism: PluginFrame gains
an optional placeId, the feed/store learn the new slot, and PlaceInspector renders
the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales,
wiki updated. No sandbox or permission change.

* fix(plugins): green the server tests + harden the new capability surface

The in-memory uninstall fixture was missing the new plugin_entity_metadata table,
so uninstall's DELETE threw "no such table" and failed the server test job. Add the
table to the fixture schema.

Self-review hardening of the write/metadata surface:
- trips.update now reproduces the web UI's per-field gate: is_archived needs
  trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a
  member who may only edit can't archive or re-cover a trip.
- Plugin metadata WRITES now also require the entity's edit permission
  (place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't
  overwrite or delete metadata another user created. Reads stay access-gated.
- Cap the metadata key length (<=256 chars) alongside the value/count quotas — the
  key was attacker-controlled and uncapped, defeating the disk-DoS guard.

* test(plugins): cover the new write/metadata deps to hold the coverage gate

The new create-rpc-host write + metadata deps were untested, dropping the
src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus
mocked core services to exercise every dep end-to-end: places/days/itinerary
create/update/delete + not-found paths, trips.update with the archive/cover
per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD
+ key/value/count caps + access checks, the costs deps, and users.getById
scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user
refusal. Tests only — no production code change.

* fix(costs): freeze FX on every cost + settlement write path (#1445)

Settled foreign-currency costs kept re-opening with a few-cent residual
when live rates drifted. The #1335 freeze only ran on the REST create/
update path, so two gaps remained:

- Foreign-currency items created via MCP create_budget_item or booking-
  import bypassed the freeze and stored exchange_rate = 1, so settlement
  re-converted them with live rates. Promote freezeForeignRate into the
  shared budgetService and call it from every write path.
- Settle-up transfers were stored currency-less and re-converted with
  live rates on each recompute. Add currency + exchange_rate to
  budget_settlements (migration), freeze the display-currency rate at
  settle time, and convert with it in calculateSettlement. Legacy rows
  (currency = NULL / rate = 1) keep live-rate behaviour until re-edited.

Also expose guarded cost update/delete to plugins: costs.update and
costs.delete under db:write:costs, gated exactly like costs.create
(addon + trip access + the acting user's budget_edit permission).
updateCost reuses BudgetService.update so a plugin write re-freezes the
FX rate too; both broadcast the same budget:updated / budget:deleted
events the REST controller emits. Wired through the host, the runtime
SDK context and the published trek-plugin-sdk (types + mock host).

* feat(plugins): provider hooks — placeDetailProvider, wired (#1429)

Turn "hooks" from a declared-but-dead surface into a real host→plugin capability.
Add an invoke.hook branch to the child + a supervisor hook registry
(providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke
transport and its timeout (a short 5s deadline so a slow provider can't delay a
response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the
in-repo runtime SDK copy was missing the `hooks` field entirely and could not even
parse a plugin that declared one — synced it with the published SDK.

The first wired hook is placeDetailProvider: a plugin returns extra rows
({label,value?,url?}) for a place, and TREK renders them natively at the foot of the
place-detail panel. Consumer is a new, additive, fail-safe endpoint
GET /api/place-details/:placeId (membership-checked; any provider that errors or
times out is simply skipped — it never breaks the panel). New hook:place-detail-
provider scope + consent chip in all 22 locales. SDK types (both copies), a
controller test, and the wiki. photoProvider/calendarSource stay reserved but the
transport now exists for them. No sandbox or CSP change.

* fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448)

Both bugs were client-only; the server already allows .pkpass and serves it
as application/vnd.apple.pkpass.

#1448: the reservation/transport attachment inputs hard-coded an accept list
that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet
MIME types) to the accept attribute in both modals.

#1447: the files-tab open path routed every non-media/non-markdown file into
the in-app PDF preview object. Add isWalletPass() and route wallet passes
through the shared blob openFile helper (as bookings already do), which
downloads them so the OS hands them to Apple Wallet.

* feat(plugins): validation/warning contributions via warningProvider hook (#1429)

Second wired provider hook, reusing the invoke.hook infra from the last commit. A
plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message,
dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its
planned date, missing booking, …). TREK surfaces them as a non-blocking overlay
banner at the top of the trip planner (the wrapper ignores pointer events so it
never covers the map/panels; only the pills are interactive).

Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId
(membership-checked; a provider that errors or times out contributes nothing and
never blocks the planner). New hook:trip-warning-provider scope + consent chip in
all 22 locales, SDK types (both copies), a controller test, and the wiki. This is
the validation half of the scheduling+validation block; feeding durations/travel
times back into core recalculation stays out (it would touch core planner
computation — deliberately deferred to keep the no-breaking-changes guarantee).

* fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit)

The adversarial audit of the #1429 additions found one real (medium) gap: the
hook:* permission was never enforced at runtime. providersOf() selected provider
plugins purely by the hooks their CODE declares (sup.hooks, reported by the child
as Object.keys(def.hooks)) and never intersected that with sup.granted — so a
plugin that merely implemented placeDetailProvider/warningProvider got wired in as
a provider even when the admin never consented to hook:place-detail-provider /
hook:trip-warning-provider. The downstream capability router still held (the hook's
ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an
auto-triggered, user-bound execution context on a passive UI browse without the
hook being consented — a consent-integrity gap that contradicts the documented
invariant.

Gate it host-side: a hookName→permission map, and providersOf now returns a plugin
only if it is active, implements the hook, AND holds the matching hook:* grant. An
unmapped hook resolves to nobody. invokeHook additionally re-checks membership in
providersOf (defense-in-depth against a direct caller). Unit test proves the
grant/implements/active intersection.

* docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco)

Fosters plugin authoring by turning the new #1429 capabilities into copy-paste
recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an
entity with metadata, contribute native place details, raise trip warnings,
broadcast, match the TREK look) linked in the sidebar, plus a complete runnable
example — trip-doctor — a hooks-only plugin that showcases warningProvider +
placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against
the SDK. Docs/example only; no product code.

* fix(collections): don't reset saved-place status to 'idea' on edit (#1437)

The update schema reused collectionStatusSchema, whose .default('idea')
survives .optional() — so a PATCH that omits status had 'idea' injected by
the validation pipe and written to the DB, clobbering 'want'/'visited'.
Strip the default on the update field with .removeDefault(), keeping the
.catch guard. Add a shared schema regression test and an e2e round-trip.

* docs(plugin): ensure plugin scopes are the same everywhere

* feat(plugins): read scopes for packing + files (#1429 eco)

Extend the read side of the capability model beyond trips/costs: db:read:packing
→ ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror
the existing trip reads exactly — the host membership-checks the trip against the
invocation's user (tripRead) before delegating to the same packingService/
fileService the REST paths use (so bags/assignees hydrate and trash is excluded),
and each is a separate scope (packing doesn't unlock files). ctx types in both SDK
copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host +
create-rpc-host tests, and the wiki (perm table + cookbook recipe).

* feat(plugins): core event subscriptions (#1429 eco)

A plugin can react to core activity by declaring events: [{ on, handler }] + the
events:subscribe grant. websocket.broadcast announces every CORE trip event (name +
tripId ONLY, never the payload) through a tiny dependency-free relay
(plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor
fans each event out to subscribed, granted, active plugins via a fire-and-forget
invoke.event on a short timeout — so a slow subscriber can never block a core write.

Safety by construction: handlers run with NO user (like a job) so trip reads are
refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the
grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re-
broadcasts are never delivered back, so handlers can't loop; and only the event name
+ tripId cross the boundary. SDK types (both copies), consent label + cap chip in all
22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook.

The relay lives in its own module (not websocket) so it doesn't drag `ws` into the
runtime and tests that mock ./websocket don't strip the sink.

* feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco)

Two author-DX wins, SDK-only.

Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list,
files.list, costs.*, places/days/itinerary writes and users.getById now return
proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile,
BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors.
Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the
raw DB row honestly (no column hidden, no false guarantees). mock-host matches.

Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM
builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a
widget builds themed UI with no CSS and no build step. Ships inlined via the same
<!-- trek:ui --> marker. Wiki updated.

* fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit)

The final eco audit found one real (medium) gap: the db:read:packing delegate
called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED
branch and returns every member's private (is_private=1) packing items — leaking
another member's personal/surprise-gift items to a plugin the normal UI/REST hides
them from. The handler had the host-bound acting user but dropped it when delegating.

Thread it through: tripRead now hands the membership-checked userId to the read
callback, packing.list forwards it to listPackingItems(tripId, userId), and the
service applies its three-tier #858 filter — a plugin now sees exactly what its user
sees. files.list is unaffected (no per-user file visibility). Tests assert the user
is passed. The other three audited surfaces (event subscriptions, trek.ui, and the
regression sweep of the capability boundary) were clean.

* security(plugins): prevent open redirect

* fix(plugins): resolve PR #1433 full-audit findings (code + tests)

The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own:

- ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser
  never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified
  the bug — corrected).
- Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app
  rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000).
- packing.list / files.list were missing from the capability audit log while every
  other core read is audited — add them to isAuditable + auditResource.
- SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string);
  the host->plugin boundary is JSON, so align both to string.
- Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing
  filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label.
- Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event
  dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's
  defense-in-depth grant re-check.

Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched).

* docs(plugins): correct the wiki against the shipped capability surface (#1433 audit)

Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked
against the code:

- Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not
  egress[]; dropped the stale "costs.create is the first and only core mutation";
  documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table
  gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta,
  hook:trip-warning-provider); the widget slot table gained place-detail.
- Plugin-Cookbook: days.create no longer passes a title the schema drops;
  broadcastToUser uses the real (userId, event, data) signature; fixed the broken
  #the-trek-ui-design-kit anchor and noted window.trek.ui.
- Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe;
  the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx.

* fix(unsplash) allow api key usage

* fix(guests): scope guest display names per-trip, not globally (#1446)

A guest is a per-trip person, but their name lived in the globally UNIQUE
users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other
trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human
name there and gets a uuid-based username that is never shown, and every member view
(members list, day-assignment participants, budget members/payers, packing
recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename
updates display_name with no dedup. Real users are unchanged (display_name NULL →
COALESCE falls through to username). Migration adds the nullable column; existing
guests keep their current username via the COALESCE fallback.

This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects
display_name. Tests: two "Jake" guests on two trips both keep the name; the two
codified-the-old-behaviour guest tests corrected.

* fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445)

The full audit found that updateSettlement called freezeForeignRate without the
"currency unchanged" guard the item path has, so any edit of a foreign-currency
settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the
frozen one — re-opening an already-balanced position with a small residual, the
exact drift #1445 was meant to prevent.

freezeForeignRate's unchanged-check was item-centric (it queried budget_items),
which a settlement (a different table) can't use. Give it an explicit
existingCurrency param; updateSettlement now reads the settlement's stored currency
and passes it, so an edit that doesn't change the currency keeps the frozen rate
(the service UPDATE already preserves exchange_rate when it's left unset). Tests
cover both: unchanged currency keeps the rate, a real currency change re-freezes.

* feat(plugins): add inter plugin dependency support and addon dependency support

* feat(plugins): add inter plugin dependency support and addon dependency support

* docs(plugins) inter dependencies

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
This commit is contained in:
jubnl
2026-07-06 00:27:42 +02:00
committed by GitHub
parent b26be30a25
commit 91025683bb
392 changed files with 11059 additions and 2360 deletions
+136
View File
@@ -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).
+1
View File
@@ -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 | | `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | | | **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `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_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` | | `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
+6
View File
@@ -63,6 +63,12 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET key: OIDC_CLIENT_SECRET
optional: true 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: volumeMounts:
- name: data - name: data
mountPath: /app/data mountPath: /app/data
+6
View File
@@ -17,6 +17,9 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }} OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }} {{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- end }} {{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -44,4 +47,7 @@ stringData:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }} {{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }} OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }} {{- end }}
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- end }} {{- end }}
+6
View File
@@ -92,6 +92,12 @@ secretEnv:
ADMIN_PASSWORD: "" ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID. # OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: "" 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 # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false generateEncryptionKey: false
+34 -7
View File
@@ -461,14 +461,16 @@ export const adminApi = {
addons: () => apiClient.get('/admin/addons').then(r => r.data), addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
plugins: () => apiClient.get('/admin/plugins').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), 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), 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), 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), 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), 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), 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), 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), pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon. // Local LLM (Ollama) management for the AI-parsing addon.
@@ -556,9 +558,34 @@ export const addonsApi = {
export const pluginsApi = { export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets). // Active plugins the client renders (page nav entries, dashboard widgets).
active: () => apiClient.get('/plugins').then(r => r.data), active: () => apiClient.get('/plugins').then(r => r.data),
// Call one of a plugin's own declared routes through the host proxy. // Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => // the server skips any slow/failing provider, so this only ever adds rows.
apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data), 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 = { 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), 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), 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), 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), 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 }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, 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), 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), 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), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
+272 -26
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import { 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, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, 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' } from 'lucide-react'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -21,6 +21,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
* tile; the security section at the bottom explains the model honestly. * 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 { interface PluginRow {
id: string id: string
name: string name: string
@@ -35,6 +41,9 @@ interface PluginRow {
source_repo: string | null source_repo: string | null
permissions: string permissions: string
capabilities: string capabilities: string
dependencies?: PluginDependencies
dependencyStatus?: DependencyStatus
dependencyIssues?: DependencyIssues
} }
interface RegistryItem { interface RegistryItem {
id: string id: string
@@ -48,6 +57,8 @@ interface RegistryItem {
minTrekVersion: string | null minTrekVersion: string | null
reviewedAt: string | null reviewedAt: string | null
screenshotUrl: string | null screenshotUrl: string | null
requiredAddons?: string[]
pluginDependencies?: PluginDep[]
} }
interface RegistryDetail extends RegistryItem { interface RegistryDetail extends RegistryItem {
size: number | null size: number | null
@@ -58,11 +69,21 @@ interface RegistryDetail extends RegistryItem {
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
license: string | null license: string | null
icon: string | null icon: string | null
requiredAddons?: string[]
pluginDependencies?: PluginDep[]
} | null } | 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, unknown>) => string type T = (k: string, p?: Record<string, unknown>) => string
type TypeFilter = 'all' | 'widget' | 'page' | 'integration' type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
type SortKey = 'name' | 'recent' | 'updates' type SortKey = 'name' | 'recent' | 'updates'
@@ -83,11 +104,15 @@ const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?:
// Known permissions → human-readable i18n key; unknown ones render as raw code. // Known permissions → human-readable i18n key; unknown ones render as raw code.
const PERM_KEYS = [ const PERM_KEYS = [
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:write:costs',
'hook:photo-provider', 'hook:calendar-source', 'http:outbound', 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips',
'db:meta',
'events:subscribe',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound',
] ]
const KNOWN_TYPES = ['widget', 'page', 'integration'] const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
function isNewer(a: string, b: string): boolean { function isNewer(a: string, b: string): boolean {
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) const nums = (v: string) => 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[] = [] const out: Cap[] = []
if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') }) 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 (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.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: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: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)) { 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 }) out.push({ icon: ArrowRight, label: h, net: true })
} }
return out 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 }) { 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 const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
return <Icon size={size} className={className} /> return <Icon size={size} className={className} />
@@ -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 (
<span className="inline-flex items-center gap-1 px-1.5 py-[2px] rounded-md text-[11px] font-medium bg-warning-soft text-warning border border-warning/25"
title={t('admin.plugins.sideloadedHint')}>
<UploadCloud size={11} /> {t('admin.plugins.sideloaded')}
</span>
)
}
function TypeBadge({ type, t }: { type: string; t: T }) { function TypeBadge({ type, t }: { type: string; t: T }) {
return ( return (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide bg-accent-subtle text-content-muted"> <span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wide bg-accent-subtle text-content-muted">
@@ -160,6 +229,8 @@ export default function AdminPluginsPanel() {
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one. // each must be shown, not silently overwritten by the last one.
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([]) const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
// 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<string | null>(null) const [menu, setMenu] = useState<string | null>(null)
// Toolbar state. // Toolbar state.
@@ -168,6 +239,11 @@ export default function AdminPluginsPanel() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all') const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [sort, setSort] = useState<SortKey>('name') const [sort, setSort] = useState<SortKey>('name')
// Sideload upload: drag a plugin .zip onto the panel or use the toolbar button.
const [dragActive, setDragActive] = useState(false)
const uploadInputRef = useRef<HTMLInputElement>(null)
const dragDepth = useRef(0)
const refresh = () => { const refresh = () => {
adminApi.plugins() adminApi.plugins()
.then((d: { enabled: boolean; plugins: PluginRow[] }) => { .then((d: { enabled: boolean; plugins: PluginRow[] }) => {
@@ -199,6 +275,43 @@ export default function AdminPluginsPanel() {
setView('discover') setView('discover')
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([])) 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<string, string> = {}
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) => { const openErrors = (id: string) => {
setMenu(null) setMenu(null)
adminApi.pluginErrors(id) 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 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)) 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 // 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 // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled
// route through the same consent dialog the Update button uses. // required addon or a missing plugin dependency (409 ADDON_DISABLED /
// DEPENDENCY_MISSING) routes to the right remedy.
const toggle = (p: PluginRow) => { const toggle = (p: PluginRow) => {
if (busy === p.id) return if (busy === p.id) return
if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
setBusy(p.id); setMenu(null) setBusy(p.id); setMenu(null)
adminApi.pluginActivate(p.id) attemptActivate(p).finally(() => { setBusy(null); refresh() })
.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 // Download a missing/outdated plugin dependency (latest compatible for its range,
if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { // transitively), then retry enabling the plugin that needed it.
setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => {
} else { if (busy === parent.id) return
toast.error(d?.error || t('admin.plugins.actionError')) 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() }) .finally(() => { setBusy(null); refresh() })
} }
@@ -285,7 +438,18 @@ export default function AdminPluginsPanel() {
const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'
return ( return (
<div className="relative bg-surface-card border border-edge rounded-2xl shadow-card mb-24 sm:mb-0"> <div className="relative bg-surface-card border border-edge rounded-2xl shadow-card mb-24 sm:mb-0"
onDragEnter={onDragEnter} onDragOver={e => { if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}>
{/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */}
<input ref={uploadInputRef} type="file" accept=".zip,.tgz,.tar.gz" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} />
{/* Drag-to-install overlay */}
{dragActive && (
<div className="absolute inset-0 z-40 rounded-2xl border-2 border-dashed border-accent bg-accent-soft/70 backdrop-blur-[2px] flex flex-col items-center justify-center gap-2.5 pointer-events-none">
<UploadCloud size={34} className="text-accent" />
<span className="text-sm font-semibold text-accent">{t('admin.plugins.dropToUpload')}</span>
</div>
)}
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />} {menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
{/* Header */} {/* Header */}
@@ -324,10 +488,16 @@ export default function AdminPluginsPanel() {
<SegBtn active={view === 'installed'} onClick={() => setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} /> <SegBtn active={view === 'installed'} onClick={() => setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
<SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} /> <SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} />
</div> </div>
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))} title={t('admin.plugins.rescan')} <div className="sm:hidden flex items-center gap-2 shrink-0">
className="sm:hidden h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors shrink-0"> <button onClick={pickUpload} title={t('admin.plugins.upload')}
<RefreshCw size={15} /> className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
</button> <UploadCloud size={15} />
</button>
<button onClick={rescan} title={t('admin.plugins.rescan')}
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<RefreshCw size={15} />
</button>
</div>
</div> </div>
<div className="relative w-full sm:flex-1 sm:min-w-[160px]"> <div className="relative w-full sm:flex-1 sm:min-w-[160px]">
@@ -344,6 +514,7 @@ export default function AdminPluginsPanel() {
options={[ options={[
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], ['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)} valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
onPick={v => setTypeFilter(v as TypeFilter)} /> onPick={v => setTypeFilter(v as TypeFilter)} />
@@ -363,7 +534,11 @@ export default function AdminPluginsPanel() {
valueLabel={sortLabel(sort, t)} valueLabel={sortLabel(sort, t)}
onPick={v => setSort(v as SortKey)} /> onPick={v => setSort(v as SortKey)} />
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))} title={t('admin.plugins.rescan')} <button onClick={pickUpload} title={t('admin.plugins.upload')}
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<UploadCloud size={15} />
</button>
<button onClick={rescan} title={t('admin.plugins.rescan')}
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors"> className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
<RefreshCw size={15} /> <RefreshCw size={15} />
</button> </button>
@@ -462,6 +637,14 @@ export default function AdminPluginsPanel() {
onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }}
/> />
)} )}
{depResolve && (
<DependencyResolveDialog
data={depResolve} t={t} busy={busy === depResolve.plugin.id} installedIds={installedIds}
onDownload={(depId, constraint) => resolveDependency(depResolve.plugin, depId, constraint)}
onClose={() => setDepResolve(null)}
/>
)}
</div> </div>
) )
} }
@@ -541,6 +724,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void
}) { }) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
const menuOpen = menu === `row:${p.id}` const menuOpen = menu === `row:${p.id}`
return ( return (
<div className="group relative flex items-center gap-3 sm:gap-4 px-2.5 sm:px-3 py-3.5 rounded-2xl hover:bg-surface-secondary transition-colors"> <div className="group relative flex items-center gap-3 sm:gap-4 px-2.5 sm:px-3 py-3.5 rounded-2xl hover:bg-surface-secondary transition-colors">
@@ -557,6 +741,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span> <span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>} {p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
{p.reviewed_at && <ReviewedBadge t={t} compact />} {p.reviewed_at && <ReviewedBadge t={t} compact />}
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
</div> </div>
{p.description && <p className="text-[12.5px] text-content-muted mt-0.5 truncate">{p.description}</p>} {p.description && <p className="text-[12.5px] text-content-muted mt-0.5 truncate">{p.description}</p>}
{p.status === 'error' && p.last_error ? ( {p.status === 'error' && p.last_error ? (
@@ -573,6 +758,16 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
))} ))}
</div> </div>
)} )}
{deps.length > 0 && (
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
{deps.map((d, i) => (
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
</span>
))}
</div>
)}
</div> </div>
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0"> <div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
@@ -597,7 +792,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} /> <MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)} )}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} /> <MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
{p.source_repo && ( {p.source_repo && p.source_repo !== 'local:upload' && (
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)} <a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => 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"> 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">
<Github size={14} /> {t('admin.plugins.sourceRepo')} <Github size={14} /> {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<string>
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
<div>
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
</div>
</div>
<div className="p-5 space-y-2.5">
{rows.map(r => (
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-content truncate">{r.id}</div>
<div className="text-[11.5px] text-content-muted mt-0.5">
{r.installed
? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed })
: t('admin.plugins.dep.requires', { version: r.constraint })}
</div>
</div>
<button onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-3 py-1.5 rounded-full bg-accent text-accent-text hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0">
<Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
</button>
</div>
))}
{rows.some(r => !installedIds.has(r.id)) && (
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
)}
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
<button onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
</div>
</div>
</div>
)
}
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible // 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. // panel that lays out how plugins are contained, the limits, and the worst case.
function SecurityInfo({ t }: { t: T }) { function SecurityInfo({ t }: { t: T }) {
@@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server' import { server } from '../../../tests/helpers/msw/server'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories' import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import CostsPanel from './CostsPanel' import CostsPanel from './CostsPanel'
@@ -164,6 +165,28 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Unfinished')).toBeInTheDocument() 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(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// 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 () => { it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null let posted: Record<string, unknown> | null = null
server.use( server.use(
+40 -17
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react' import { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' 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 { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
@@ -179,6 +179,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const myShare = shares[me] || 0 const myShare = shares[me] || 0
return convert(myShare, curOf(e)) 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 totals = useMemo(() => {
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) 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 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 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) 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]) }, [budgetItems, settlement, me])
// ── filtering + day grouping ──────────────────────────────────────────── // ── filtering + day grouping ────────────────────────────────────────────
@@ -254,7 +259,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// ── settle actions ────────────────────────────────────────────────────── // ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => { const settleFlow = async (fromId: number, toId: number, amount: number) => {
try { 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() loadSettlement()
} catch { toast.error(t('common.unknownError')) } } catch { toast.error(t('common.unknownError')) }
} }
@@ -265,7 +270,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const flows = settlement?.flows || [] const flows = settlement?.flows || []
if (!flows.length) return if (!flows.length) return
try { 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() loadSettlement()
} catch { toast.error(t('common.unknownError')) } } catch { toast.error(t('common.unknownError')) }
} }
@@ -369,7 +374,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div> </div>
{/* ── Summary cards ── */} {/* ── Summary cards ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary"> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 36 }} className="costs-summary">
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale} <SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
icon={<ArrowDown size={18} />} tone="owe" icon={<ArrowDown size={18} />} tone="owe"
foot={totals.owe > 0.01 foot={totals.owe > 0.01
@@ -380,6 +385,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
foot={totals.owed > 0.01 foot={totals.owed > 0.01
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} /> ? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} /> : <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
<SummaryCard label={t('costs.outstanding')} sub={t('costs.outstandingSub')} amount={totals.outstanding} currency={base} locale={locale}
icon={<AlertCircle size={18} />} tone="unfinished"
foot={totals.outstandingCount > 0
? <span><b>{totals.outstandingCount}</b> {t('costs.outstandingItems')}</span>
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale} <SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
icon={<BarChart3 size={18} />} tone="total" icon={<BarChart3 size={18} />} tone="total"
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} /> foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
@@ -475,7 +485,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)} )}
{(editingSettlement || addingPayment) && ( {(editingSettlement || addingPayment) && (
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} <SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement} currency={base}
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }} onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> 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 .text-content-faint { color: var(--c-ink3) !important; }
.costs-root .exp-actions { opacity: 1; } .costs-root .exp-actions { opacity: 1; }
@media (max-width: 1100px) { @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; } .costs-root .costs-grid { grid-template-columns: 1fr !important; }
} }
@media (max-width: 640px) {
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
}
`}</style> `}</style>
</div> </div>
) )
@@ -580,6 +593,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div> </div>
</div> </div>
{/* Outstanding */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', background: '#d9770622', color: '#d97706', flexShrink: 0 }}><AlertCircle size={17} /></div>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('costs.outstanding')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('costs.outstandingSub')}</div>
</div>
<div style={{ marginLeft: 'auto', fontSize: 'calc(27px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', color: '#d97706' }}>{bigMoney(totals.outstanding, 16, 'var(--c-ink3)')}</div>
</div>
</div>
{/* Settle up */} {/* Settle up */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}> <div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
@@ -645,21 +670,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const cur = curOf(e) const cur = curOf(e)
const payers = (e.payers || []).filter(p => p.amount > 0) const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e)) const net = round2(myPaidOf(e) - myShareOf(e))
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip const unfinished = isUnfinished(e)
// total but stays out of settlements until who-paid is filled in.
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return ( return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}> <div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}> <span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} /> <Icon size={21} />
{isMobile && isUnfinished && ( {isMobile && unfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span> <span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)} )}
</span> </span>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span> <span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
{isUnfinished && !isMobile && ( {unfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}> <span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span> <span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
{t('costs.unfinished')} {t('costs.unfinished')}
@@ -786,9 +809,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
} }
// ── pure subcomponents ───────────────────────────────────────────────────── // ── 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 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)' const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling // formatToParts keeps the design's "big integer + muted symbol/decimals" styling
// while letting Intl place the symbol and pick separators per locale + currency. // 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 // 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 // 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. // X" works the same whether or not there's an outstanding expense behind it.
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: { function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: {
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -853,7 +876,7 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
const save = async () => { const save = async () => {
if (!valid) return if (!valid) return
setSaving(true) 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 { try {
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
else await budgetApi.createSettlement(tripId, data) else await budgetApi.createSettlement(tripId, data)
@@ -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);
});
});
@@ -26,6 +26,18 @@ export function isMarkdown(mimeType?: string | null, name?: string | null) {
return !!mimeType && (mimeType === 'text/markdown' || mimeType === 'text/x-markdown') 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) { export function getFileIcon(mimeType?: string | null) {
if (!mimeType) return File if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText if (mimeType === 'application/pdf') return FileText
@@ -15,6 +15,15 @@ vi.mock('../../api/authUrl', () => ({
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'), 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 // 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. // pipeline is heavy in jsdom) — we only assert the markdown text reaches the modal.
vi.mock('react-markdown', () => ({ 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(<FileManager {...defaultProps} files={files} />);
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', () => { it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })]; const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
render(<FileManager {...defaultProps} files={files} />); render(<FileManager {...defaultProps} files={files} />);
@@ -7,7 +7,8 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl' 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 { export interface FileManagerProps {
files?: TripFile[] files?: TripFile[]
@@ -191,6 +192,10 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
if (isMedia(file.mime_type)) { if (isMedia(file.mime_type)) {
const idx = mediaFiles.findIndex(f => f.id === file.id) const idx = mediaFiles.findIndex(f => f.id === file.id)
setLightboxIndex(idx >= 0 ? idx : 0) 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 { } else {
setPreviewFile(file) setPreviewFile(file)
} }
@@ -129,6 +129,7 @@ beforeEach(() => {
resetAllStores() resetAllStores()
vi.clearAllMocks() vi.clearAllMocks()
sessionStorage.clear() sessionStorage.clear()
localStorage.clear()
// Reset mutable day-notes state // Reset mutable day-notes state
mockDayNotesState.noteUi = {} mockDayNotesState.noteUi = {}
mockDayNotesState.dayNotes = {} mockDayNotesState.dayNotes = {}
@@ -96,6 +96,9 @@ interface DayPlanSidebarProps {
onScrollTopChange?: (top: number) => void 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 */ /** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean 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, initialScrollTop,
onScrollTopChange, onScrollTopChange,
showRouteToolsWhenExpanded = false, showRouteToolsWhenExpanded = false,
isMobile = false,
} = props } = props
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
@@ -153,7 +157,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [expandedDays, setExpandedDays] = useState(() => { const [expandedDays, setExpandedDays] = useState(() => {
try { try {
const saved = sessionStorage.getItem(`day-expanded-${tripId}`) const saved = localStorage.getItem(`day-expanded-${tripId}`)
if (saved) return new Set<number>(JSON.parse(saved) as number[]) if (saved) return new Set<number>(JSON.parse(saved) as number[])
} catch {} } catch {}
return new Set<number>(days.map(d => d.id)) return new Set<number>(days.map(d => d.id))
@@ -260,7 +264,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setExpandedDays(prev => { setExpandedDays(prev => {
const n = new Set(prev) const n = new Set(prev)
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) }) 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 return n
}) })
} }
@@ -293,7 +297,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setExpandedDays(prev => { setExpandedDays(prev => {
const n = new Set(prev) const n = new Set(prev)
n.has(dayId) ? n.delete(dayId) : n.add(dayId) 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 return n
}) })
} }
@@ -1019,6 +1023,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
initialScrollTop, initialScrollTop,
onScrollTopChange, onScrollTopChange,
showRouteToolsWhenExpanded, showRouteToolsWhenExpanded,
isMobile,
toast, toast,
t, t,
language, language,
@@ -1184,6 +1189,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
initialScrollTop, initialScrollTop,
onScrollTopChange, onScrollTopChange,
showRouteToolsWhenExpanded, showRouteToolsWhenExpanded,
isMobile,
toast, toast,
t, t,
language, language,
@@ -1648,9 +1654,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return ( return (
<React.Fragment key={`place-${assignment.id}`}> <React.Fragment key={`place-${assignment.id}`}>
<div <div
draggable={canEditDays} draggable={canEditDays && !isMobile}
onDragStart={e => { onDragStart={e => {
if (!canEditDays) { e.preventDefault(); return } if (!canEditDays || isMobile) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id)) e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
@@ -1745,7 +1751,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
opacity: isDraggingThis ? 0.4 : 1, opacity: isDraggingThis ? 0.4 : 1,
}} }}
> >
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}> {canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} /> <GripVertical size={13} strokeWidth={1.8} />
</div>} </div>}
<div <div
@@ -2005,9 +2011,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}` const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key) if (dropTargetRef.current !== key) setDropTargetKey(key)
}} }}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg} draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile}
onDragStart={e => { onDragStart={e => {
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 // setData is required for the drag to start reliably (Firefox) and
// matches how place/note items initiate their drag. // matches how place/note items initiate their drag.
e.dataTransfer.setData('reservationId', String(res.id)) 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, opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
}} }}
> >
{canEditDays && spanPhase !== 'middle' && !res.__leg && ( {canEditDays && spanPhase !== 'middle' && !res.__leg && !isMobile && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}> <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} /> <GripVertical size={13} strokeWidth={1.8} />
</div> </div>
@@ -2173,8 +2179,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return ( return (
<React.Fragment key={`note-${note.id}`}> <React.Fragment key={`note-${note.id}`}>
<div <div
draggable={canEditDays} draggable={canEditDays && !isMobile}
onDragStart={e => { 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}`) }} 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 }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => { onDrop={e => {
@@ -2242,7 +2248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}} }}
> >
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}> {canEditDays && !isMobile && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} /> <GripVertical size={13} strokeWidth={1.8} />
</div>} </div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}> <div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
@@ -259,8 +259,8 @@ describe('PlaceInspector', () => {
onRemoveAssignment={onRemoveAssignment} onRemoveAssignment={onRemoveAssignment}
/> />
); );
// Find the remove button — it has "Remove" text (sm:hidden span) // Find the remove button — it carries the "Remove from Day" label
const removeBtn = screen.getByText('Remove').closest('button')!; const removeBtn = screen.getByText('Remove from Day').closest('button')!;
await user.click(removeBtn); await user.click(removeBtn);
// Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id) // Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id)
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99); expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99);
@@ -8,7 +8,7 @@ import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import GuestBadge from '../shared/GuestBadge' import GuestBadge from '../shared/GuestBadge'
import StatusBadge from '../Collections/StatusBadge' import StatusBadge from '../Collections/StatusBadge'
import { mapsApi } from '../../api/client' import { mapsApi, pluginsApi } from '../../api/client'
import { collectionsApi } from '../../api/collections' import { collectionsApi } from '../../api/collections'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
@@ -16,6 +16,8 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation, translateApiError } from '../../i18n' 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 { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import type { CollectionStatus } from '@trek/shared' import type { CollectionStatus } from '@trek/shared'
import { splitReservationDateTime, formatTime } from '../../utils/formatters' import { splitReservationDateTime, formatTime } from '../../utils/formatters'
@@ -142,6 +144,21 @@ export default function PlaceInspector({
leftWidth = 0, rightWidth = 0, leftWidth = 0, rightWidth = 0,
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList,
}: PlaceInspectorProps) { }: 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<Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }>>([])
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 { t, locale, language } = useTranslation()
const toast = useToast() const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
@@ -356,6 +373,34 @@ export default function PlaceInspector({
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
distanceUnit={distanceUnit} /> distanceUnit={distanceUnit} />
{/* Extra native rows from placeDetailProvider plugins (#1429). */}
{mode === 'trip' && providerDetails.length > 0 && (
<div className="bg-surface-hover" style={{ borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{providerDetails.flatMap((p) => p.items.map((it, i) => (
<div key={`${p.pluginId}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>
<span className="text-content-secondary" style={{ fontWeight: 500, flexShrink: 0 }}>{it.label}</span>
{it.url
? <a href={it.url} target="_blank" rel="noreferrer noopener" className="text-accent" style={{ textDecoration: 'none', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis' }}>{it.value ?? '↗'}</a>
: <span className="text-content-muted" style={{ textAlign: 'right' }}>{it.value}</span>}
</div>
)))}
</div>
)}
{/* Place-detail plugin slots (#1429): sandboxed, scoped to this place. */}
{mode === 'trip' && placeDetailPlugins.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{placeDetailPlugins.map((p) => {
const tid = (place as { trip_id?: number | string }).trip_id
return (
<div key={p.id} className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden' }}>
<PluginFrame pluginId={p.id} tripId={tid != null ? String(tid) : null} placeId={String(place.id)} title={p.name} />
</div>
)
})}
</div>
)}
</div> </div>
{/* Footer actions */} {/* Footer actions */}
@@ -372,7 +417,7 @@ export default function PlaceInspector({
{mode === 'trip' && selectedDayId && ( {mode === 'trip' && selectedDayId && (
assignmentInDay ? ( assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />} <ActionButton onClick={() => onRemoveAssignment?.(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} /> label={<span className="hidden sm:inline">{t('inspector.removeFromDay')}</span>} />
) : ( ) : (
<ActionButton onClick={() => onAssignToDay?.(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} /> <ActionButton onClick={() => onAssignToDay?.(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
) )
@@ -23,13 +23,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
} = S } = S
return ( return (
<div <div
onDragEnter={handleSidebarDragEnter} onDragEnter={isMobile ? undefined : handleSidebarDragEnter}
onDragOver={handleSidebarDragOver} onDragOver={isMobile ? undefined : handleSidebarDragOver}
onDragLeave={handleSidebarDragLeave} onDragLeave={isMobile ? undefined : handleSidebarDragLeave}
onDrop={handleSidebarDrop} onDrop={isMobile ? undefined : handleSidebarDrop}
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }} style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
> >
{sidebarDragOver && <PlacesDropOverlay {...S} />} {!isMobile && sidebarDragOver && <PlacesDropOverlay {...S} />}
{/* Kopfbereich */} {/* Kopfbereich */}
<PlacesHeader {...S} /> <PlacesHeader {...S} />
@@ -36,8 +36,9 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
ref={element => registerPlaceRow(place.id, element)} ref={element => registerPlaceRow(place.id, element)}
aria-selected={isSelected} aria-selected={isSelected}
data-place-id={place.id} data-place-id={place.id}
draggable={!selectMode} draggable={!selectMode && !isMobile}
onDragStart={e => { onDragStart={e => {
if (isMobile) { e.preventDefault(); return }
e.dataTransfer.setData('placeId', String(place.id)) e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy' e.dataTransfer.effectAllowed = 'copy'
window.__dragData = { placeId: String(place.id) } window.__dragData = { placeId: String(place.id) }
@@ -55,7 +56,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
style={{ style={{
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px', 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', background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)', borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s', transition: 'background 0.1s',
@@ -409,6 +409,12 @@ describe('ReservationModal', () => {
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
}); });
it('FE-PLANNER-RESMODAL-029b: file input accepts pkpass (#1448)', () => {
render(<ReservationModal {...defaultProps} />);
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 () => { it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => {
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />); render(<ReservationModal {...defaultProps} onSave={onSave} />);
@@ -682,7 +682,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</button> </button>
</div> </div>
))} ))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} /> <input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{ {onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
@@ -71,6 +71,12 @@ describe('TransportModal', () => {
expect(screen.getByText(/Add transport/i)).toBeInTheDocument(); expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
}); });
it('FE-PLANNER-TRANSMODAL-002b: file input accepts pkpass (#1448)', () => {
render(<TransportModal {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput.accept).toContain('.pkpass');
});
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => { it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />); render(<TransportModal {...defaultProps} reservation={res} />);
@@ -946,7 +946,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</button> </button>
</div> </div>
))} ))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} /> <input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,.pkpass,.pkpasses,image/*,application/vnd.apple.pkpass,application/vnd.apple.pkpasses" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{ {onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} className="text-content-faint" style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { pluginsApi } from '../../api/client'
/**
* Shows validation/warning contributions from `warningProvider` plugins (#1429).
* Self-contained + fail-safe: the server skips any slow/failing provider, so this
* only ever adds rows; it renders nothing (and takes no space) when there are none.
*/
type Warning = { pluginId: string; level: 'info' | 'warning' | 'error'; message: string }
const STYLE = {
info: { Icon: Info, color: 'var(--info)', bg: 'var(--info-soft)' },
warning: { Icon: AlertTriangle, color: 'var(--warning)', bg: 'var(--warning-soft)' },
error: { Icon: AlertCircle, color: 'var(--danger)', bg: 'var(--danger-soft)' },
} as const
export default function TripWarningsBanner({ tripId }: { tripId: number }) {
const [warnings, setWarnings] = useState<Warning[]>([])
useEffect(() => {
if (!Number.isFinite(tripId)) { setWarnings([]); return }
let cancelled = false
pluginsApi.tripWarnings(tripId)
.then((d) => { if (!cancelled) setWarnings(d.warnings || []) })
.catch(() => { if (!cancelled) setWarnings([]) })
return () => { cancelled = true }
}, [tripId])
if (warnings.length === 0) return null
// A non-blocking overlay pinned to the top of the (fixed) planner content region:
// the wrapper ignores pointer events so it never covers the map/panels, and only
// the warning pills themselves are interactive.
return (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 6, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 16px' }}>
{warnings.map((w, i) => {
const s = STYLE[w.level] ?? STYLE.warning
return (
<div key={`${w.pluginId}-${i}`} style={{ pointerEvents: 'auto', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 10, background: s.bg, color: s.color, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, boxShadow: 'var(--shadow-card)' }}>
<s.Icon size={15} style={{ flexShrink: 0 }} />
<span>{w.message}</span>
</div>
)
})}
</div>
)
}
@@ -9,7 +9,8 @@ const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate })); vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast })); vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) })); vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7 } }) })); vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7, username: 'ada', avatar_url: null, role: 'admin' } }) }));
vi.mock('../../store/settingsStore', () => ({ useSettingsStore: (sel: (s: unknown) => unknown) => sel({ settings: { default_currency: 'EUR', time_format: '24h', distance_unit: 'metric', temperature_unit: 'celsius' } }) }));
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } })); vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
function fromFrame(frame: HTMLIFrameElement, data: unknown) { function fromFrame(frame: HTMLIFrameElement, data: unknown) {
@@ -64,4 +65,40 @@ describe('PluginFrame', () => {
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined })); await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true)); await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
}); });
it('FE-PLUGINS-FRAME-005: context carries theme tokens, formats and non-secret display identity', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx).toBeTruthy();
expect(ctx!.tokens).toBeTruthy(); // resolved design tokens (empty {} in jsdom, but present)
expect(ctx!.formats).toMatchObject({ currency: 'EUR', timeFormat: '24h', distanceUnit: 'metric' });
// Display identity is present but carries NO secret (no email, role only as a boolean).
expect(ctx!.user).toMatchObject({ name: 'ada', isAdmin: true });
expect(JSON.stringify(ctx)).not.toContain('@'); // no email leaked
});
it('FE-PLUGINS-FRAME-006: context mirrors the host appearance state (scheme/density/flags)', () => {
const { container } = render(<PluginFrame pluginId="demo" />);
const iframe = container.querySelector('iframe')!;
const posted: Array<Record<string, unknown>> = [];
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record<string, unknown>);
fromFrame(iframe, { type: 'trek:context:request' });
const ctx = posted.find((m) => m.type === 'trek:context') as Record<string, unknown> | undefined;
expect(ctx).toBeTruthy();
// A plugin can honour the same accent/density/accessibility choices as the host.
expect(ctx!.appearance).toMatchObject({
scheme: 'default',
density: 'comfortable',
noTransparency: false,
reducedMotion: false,
});
});
}); });
+138 -15
View File
@@ -1,10 +1,68 @@
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { pluginsApi } from '../../api/client' import { pluginsApi } from '../../api/client'
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
// re-match on a theme toggle / accent change) instead of hard-coding a mirror of
// the palette that drifts. Names mirror index.css so a plugin can apply them
// verbatim as CSS variables. This is the whole GLOBAL (:root/.dark) palette — the
// part that a user can recolour via appearance settings (accent scheme, custom
// accent, high-contrast) flows through here live. The glassy `.trek-dash` layer
// (--glass-*/--r-*/--sh-*) is intentionally NOT read here: it is scoped to the
// dashboard subtree, so it resolves EMPTY at documentElement — the SDK design kit
// bakes those values instead (they don't vary with the accent, only light/dark).
const TOKEN_VARS = [
// surfaces
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-elevated',
'--bg-card', '--bg-input', '--bg-hover', '--bg-selected', '--bg-inverse',
// text
'--text-primary', '--text-secondary', '--text-muted', '--text-faint', '--text-inverse',
// borders
'--border-primary', '--border-secondary', '--border-faint',
// accent (recoloured by the chosen scheme / custom accent)
'--accent', '--accent-text', '--accent-on', '--accent-hover', '--accent-subtle',
// semantic + soft fills
'--success', '--success-soft', '--danger', '--danger-soft',
'--warning', '--warning-soft', '--info', '--info-soft',
// shadows
'--shadow-card', '--shadow-elevated', '--shadow-sm', '--shadow-md', '--shadow-lg',
// radii, type, misc
'--radius-sm', '--radius-md', '--radius-lg', '--radius-xl',
'--font-system', '--font-subtext', '--overlay', '--ease-out-quint',
]
function readThemeTokens(): Record<string, string> {
const cs = getComputedStyle(document.documentElement)
const out: Record<string, string> = {}
for (const v of TOKEN_VARS) {
const val = cs.getPropertyValue(v).trim()
if (val) out[v] = val
}
return out
}
/**
* The host's current appearance state, mirrored from the attributes applyAppearance
* writes on <html>, so a plugin can honour the same accessibility/appearance choices
* inside its own sandboxed document (it can't read the parent DOM). All booleans/enums
* — nothing secret.
*/
function readAppearance() {
const el = document.documentElement
return {
scheme: el.dataset.scheme || 'default',
density: el.dataset.density === 'compact' ? 'compact' : 'comfortable',
noTransparency: el.hasAttribute('data-no-transparency'),
reducedMotion:
el.hasAttribute('data-reduce-motion') ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
}
}
/** /**
* Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge * Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge
* (#plugins, M3). * (#plugins, M3).
@@ -21,6 +79,8 @@ import { pluginsApi } from '../../api/client'
interface PluginFrameProps { interface PluginFrameProps {
pluginId: string pluginId: string
tripId?: string | null tripId?: string | null
/** The place in view — set for a place-detail slot so the plugin can scope to it. */
placeId?: string | null
className?: string className?: string
title?: string title?: string
} }
@@ -33,7 +93,7 @@ type Inbound =
| { type: 'trek:resize'; height?: number } | { type: 'trek:resize'; height?: number }
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown } | { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
export default function PluginFrame({ pluginId, tripId = null, className, title }: PluginFrameProps) { export default function PluginFrame({ pluginId, tripId = null, placeId = null, className, title }: PluginFrameProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null) const frameRef = useRef<HTMLIFrameElement | null>(null)
// A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its // A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its
// window identity keeps matching our iframe afterwards. Track loads and refuse // window identity keeps matching our iframe afterwards. Track loads and refuse
@@ -48,22 +108,49 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const userId = useAuthStore((s) => s.user?.id) const userId = useAuthStore((s) => s.user?.id)
const userName = useAuthStore((s) => s.user?.username ?? null)
const userAvatar = useAuthStore((s) => s.user?.avatar_url ?? null)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const settings = useSettingsStore((s) => s.settings)
const [height, setHeight] = useState<number | null>(null) const [height, setHeight] = useState<number | null>(null)
// opaque frame -> targetOrigin must be '*'. Hoisted so the iframe's onLoad can
// deliver the context too: the trek:ready handshake alone is racy — if the frame
// boots before the effect's listener attaches, the plugin never learns the theme
// and falls back to the OS scheme (dark mode looking "off" until a toggle).
const postFrame = useCallback((msg: unknown) => frameRef.current?.contentWindow?.postMessage(msg, '*'), [])
const buildContext = useCallback(() => ({
type: 'trek:context',
tripId,
placeId,
userId: userId != null ? String(userId) : null,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
locale,
hostOrigin: window.location.origin,
// #4 richer context — non-secret display data so plugins render natively:
// who the user is (name/avatar/isAdmin — never email/role beyond a boolean),
// how TREK formats things, the resolved theme tokens, and the appearance state
// (accent scheme, density, reduced-motion / no-transparency) so a plugin can
// mirror the same look and accessibility choices as the host.
user: userName != null ? { name: userName, avatar: userAvatar, isAdmin } : null,
appearance: readAppearance(),
formats: {
locale,
currency: settings.default_currency,
timeFormat: settings.time_format,
distanceUnit: settings.distance_unit,
temperatureUnit: settings.temperature_unit,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
tokens: readThemeTokens(),
}), [tripId, placeId, userId, locale, userName, userAvatar, isAdmin, settings])
useEffect(() => { useEffect(() => {
const frame = frameRef.current const frame = frameRef.current
if (!frame) return if (!frame) return
const post = (msg: unknown) => frame.contentWindow?.postMessage(msg, '*') // opaque frame -> targetOrigin must be '*' const post = postFrame
const context = buildContext
const context = () => ({
type: 'trek:context',
tripId,
userId: userId != null ? String(userId) : null,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
locale,
hostOrigin: window.location.origin,
})
const onMessage = async (ev: MessageEvent) => { const onMessage = async (ev: MessageEvent) => {
// The ONLY trusted identity: the message came from OUR iframe's window. // The ONLY trusted identity: the message came from OUR iframe's window.
@@ -109,14 +196,50 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
} }
window.addEventListener('message', onMessage) window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [pluginId, tripId, userId, locale, navigate, toast]) // The frame is opaque-origin and can't read our DOM, and we otherwise send the
// context (incl. theme + tokens) only once on trek:ready — so a plugin can't
// follow an in-app appearance change. Watch the <html> element for anything
// applyAppearance touches (the `dark` class, the data-* appearance attributes,
// and inline style for the custom-accent vars) and re-post the context when the
// resulting look actually changes, so plugins restyle live. A compact signature
// dedupes: unrelated mutations don't trigger a repost. (Plugins re-apply on
// trek:context.)
const htmlEl = document.documentElement
const appearanceSig = () => {
const cs = getComputedStyle(htmlEl)
return [
htmlEl.classList.contains('dark'),
htmlEl.dataset.scheme || '',
htmlEl.dataset.density || '',
htmlEl.hasAttribute('data-no-transparency'),
htmlEl.hasAttribute('data-reduce-motion'),
cs.getPropertyValue('--accent').trim(),
].join('|')
}
let prevSig = appearanceSig()
const themeObserver = new MutationObserver(() => {
const sig = appearanceSig()
if (sig === prevSig) return
prevSig = sig
if (loadsRef.current <= 1) post(context())
})
themeObserver.observe(htmlEl, {
attributes: true,
attributeFilter: ['class', 'style', 'data-scheme', 'data-density', 'data-no-transparency', 'data-reduce-motion'],
})
return () => { window.removeEventListener('message', onMessage); themeObserver.disconnect() }
}, [pluginId, navigate, toast, postFrame, buildContext])
return ( return (
<iframe <iframe
ref={frameRef} ref={frameRef}
src={`/plugin-frame/${pluginId}/index.html`} src={`/plugin-frame/${pluginId}/index.html`}
onLoad={() => { loadsRef.current += 1 }} // Deliver the context as soon as the document is parsed (the plugin sets up its
// message listener during parse), closing the trek:ready race so the theme is
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
sandbox="allow-scripts allow-forms" sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
loading="lazy" loading="lazy"
@@ -1,21 +1,46 @@
import { Blocks } from 'lucide-react'
import PluginFrame from './PluginFrame' import PluginFrame from './PluginFrame'
import type { ActivePlugin } from '../../store/pluginStore' import type { ActivePlugin } from '../../store/pluginStore'
/** /**
* Renders active `widget` plugins as dashboard cards (#plugins, M8). Each is a * Renders active `widget` plugins as dashboard cards (#plugins, M8). Each is a
* sandboxed PluginFrame; the widget talks to TREK only over the bridge. * sandboxed PluginFrame; the widget talks to TREK only over the bridge.
*
* The card mirrors the native dashboard tools (glassy `.tool` surface + uppercase
* title) so plugins sit alongside them seamlessly, and the body auto-sizes to the
* height the widget reports over trek:resize — no fixed height that would clip a
* taller widget's controls.
*/ */
export default function PluginWidgets({ plugins, tripId = null }: { plugins: ActivePlugin[]; tripId?: string | null }) { export default function PluginWidgets({ plugins, tripId = null }: { plugins: ActivePlugin[]; tripId?: string | null }) {
if (plugins.length === 0) return null if (plugins.length === 0) return null
return ( return (
<> <>
{plugins.map((p) => ( {plugins.map((p) => (
<div key={p.id} className="bg-surface-card border border-edge rounded-xl overflow-hidden"> <div
<div className="px-3 py-2 text-xs font-medium text-content-muted border-b border-edge-secondary flex items-center gap-1.5"> key={p.id}
{p.name} style={{
background: 'var(--glass-bg)',
border: '1px solid var(--glass-border)',
borderRadius: 'var(--r-xl, 20px)',
boxShadow: 'var(--glass-shadow), var(--glass-highlight)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '16px 20px 8px',
fontSize: 13, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.14em',
color: 'var(--ink-3)',
}}
>
<Blocks size={14} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</div> </div>
<div style={{ height: 180 }}> {/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} className="w-full h-full" /> <div style={{ minHeight: 60 }}>
<PluginFrame pluginId={p.id} tripId={tripId} title={p.name} />
</div> </div>
</div> </div>
))} ))}
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Modal({
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div <div
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]" className="fixed inset-0 z-[10000] flex items-start sm:items-center justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }} style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }} onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => { onClick={e => {
+31 -1
View File
@@ -703,7 +703,8 @@ describe('AdminPage', () => {
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i })); fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const keyInput = await screen.findByPlaceholderText('Enter key...'); // Maps is the first 'Enter key...' input (Unsplash is the second).
const keyInput = (await screen.findAllByPlaceholderText('Enter key...'))[0];
// Type a value — covers the onChange handler // Type a value — covers the onChange handler
fireEvent.change(keyInput, { target: { value: 'test-api-key-abc123' } }); fireEvent.change(keyInput, { target: { value: 'test-api-key-abc123' } });
@@ -978,6 +979,35 @@ describe('AdminPage', () => {
expect(capturedBody).toMatchObject({ maps_api_key: 'test-maps-key-123' }); expect(capturedBody).toMatchObject({ maps_api_key: 'test-maps-key-123' });
}); });
}); });
it('typing in the Unsplash API key and clicking Save sends unsplash_api_key', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
}),
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
// The Unsplash key is the second 'Enter key...' input (after Maps).
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
await waitFor(() => {
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
});
});
}); });
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => { describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
+33
View File
@@ -901,5 +901,38 @@ describe('DashboardPage', () => {
expect(s.dashboard_fx_to).toBe('CHF'); expect(s.dashboard_fx_to).toBe('CHF');
expect(s.dashboard_timezones).toEqual(['America/New_York']); expect(s.dashboard_timezones).toEqual(['America/New_York']);
}); });
it('keeps the legacy localStorage keys when the server write fails, so nothing is lost (#1311)', async () => {
// The write to persist the migrated values fails (server briefly unavailable during a
// docker upgrade). The legacy localStorage source must survive so the next load retries —
// the old code deleted it unconditionally, permanently losing the values.
server.use(
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
);
localStorage.setItem('trek_fx_from', 'CAD');
localStorage.setItem('trek_fx_to', 'CHF');
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<DashboardPage />);
// The optimistic store update proves the migration effect ran; the failed write must NOT
// have removed the localStorage source.
await waitFor(() => {
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD');
});
expect(localStorage.getItem('trek_fx_from')).toBe('CAD');
expect(localStorage.getItem('trek_fx_to')).toBe('CHF');
expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York']));
});
it('drops a malformed legacy timezone value instead of retrying forever (#1311)', async () => {
localStorage.setItem('trek_dashboard_tz', 'not-json');
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<DashboardPage />);
await waitFor(() => {
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
});
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
});
}); });
}); });
+18 -9
View File
@@ -618,10 +618,15 @@ function CurrencyTool(): React.ReactElement {
const lf = localStorage.getItem('trek_fx_from') const lf = localStorage.getItem('trek_fx_from')
const lt = localStorage.getItem('trek_fx_to') const lt = localStorage.getItem('trek_fx_to')
if (!lf && !lt) return if (!lf && !lt) return
if (lf) updateSetting('dashboard_fx_from', lf).catch(() => {}) const writes: Promise<void>[] = []
if (lt) updateSetting('dashboard_fx_to', lt).catch(() => {}) if (lf) writes.push(updateSetting('dashboard_fx_from', lf))
localStorage.removeItem('trek_fx_from') if (lt) writes.push(updateSetting('dashboard_fx_to', lt))
localStorage.removeItem('trek_fx_to') // Only drop the localStorage source once the server has durably stored the values, so a
// failed write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
Promise.all(writes).then(() => {
localStorage.removeItem('trek_fx_from')
localStorage.removeItem('trek_fx_to')
}).catch(() => { /* keep localStorage; retry on next load */ })
}, [isLoaded, updateSetting]) }, [isLoaded, updateSetting])
const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK
@@ -697,11 +702,15 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
if (!isLoaded) return if (!isLoaded) return
const raw = localStorage.getItem('trek_dashboard_tz') const raw = localStorage.getItem('trek_dashboard_tz')
if (!raw) return if (!raw) return
try { let parsed: unknown
const parsed = JSON.parse(raw) // A malformed/non-array value can never be written, so drop it now to avoid retrying forever.
if (Array.isArray(parsed)) updateSetting('dashboard_timezones', parsed).catch(() => {}) try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return }
} catch { /* ignore malformed storage */ } if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return }
localStorage.removeItem('trek_dashboard_tz') // Only drop the localStorage source once the server has durably stored the value, so a failed
// write during a (docker) upgrade can't destroy the only copy (#1311). Retry next load.
updateSetting('dashboard_timezones', parsed)
.then(() => { localStorage.removeItem('trek_dashboard_tz') })
.catch(() => { /* keep localStorage; retry on next load */ })
}, [isLoaded, updateSetting]) }, [isLoaded, updateSetting])
const allZones = React.useMemo<string[]>(() => { const allZones = React.useMemo<string[]>(() => {
+13 -1
View File
@@ -30,6 +30,8 @@ import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Bud
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types' import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../types' import type { BudgetItem } from '../types'
import CollabPanel from '../components/Collab/CollabPanel' import CollabPanel from '../components/Collab/CollabPanel'
import PluginFrame from '../components/Plugins/PluginFrame'
import TripWarningsBanner from '../components/Planner/TripWarningsBanner'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
@@ -298,6 +300,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
{/* Offset by navbar + tab bar (44px) */} {/* Offset by navbar + tab bar (44px) */}
<div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}> <div style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px)', left: 0, right: 0, bottom: 0, overflow: 'hidden', overscrollBehavior: 'contain' }}>
{/* Plugin validation/warning contributions (#1429) — non-blocking overlay. */}
<TripWarningsBanner tripId={tripId} />
{activeTab === 'plan' && ( {activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
<MapView <MapView
@@ -376,6 +381,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
opacity: leftCollapsed ? 0 : 1, opacity: leftCollapsed ? 0 : 1,
}}> }}>
<DayPlanSidebar <DayPlanSidebar
isMobile={isMobile}
tripId={tripId} tripId={tripId}
trip={trip} trip={trip}
days={days} days={days}
@@ -624,7 +630,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> : <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
} }
</div> </div>
@@ -709,6 +715,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} /> <CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
</div> </div>
)} )}
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
<PluginFrame pluginId={activeTab.slice('plugin:'.length)} tripId={String(tripId)} className="w-full h-full" />
</div>
)}
</div> </div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} /> <PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} />
+25 -1
View File
@@ -26,7 +26,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passkeyLogin, setPasskeyLogin, passkeyConfigured, passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, showKeys, savingKeys, validating, validation, mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
setShowRotateJwtModal, setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa, handleToggleAuthSetting, handleToggleRequireMfa,
toggleKey, handleSaveApiKeys, handleValidateKey, toggleKey, handleSaveApiKeys, handleValidateKey,
@@ -304,6 +304,30 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
)} )}
</div> </div>
{/* Unsplash Key */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
{t('admin.unsplashKey')}
</label>
<div className="relative">
<input
type={showKeys.unsplash ? 'text' : 'password'}
value={unsplashKey}
onChange={e => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
type="button"
onClick={() => toggleKey('unsplash')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.unsplash ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.unsplashKeyHint')}</p>
</div>
{/* Place Photos Toggle */} {/* Place Photos Toggle */}
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100"> <div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
<div> <div>
+6 -3
View File
@@ -97,6 +97,7 @@ export function useAdmin() {
// API Keys // API Keys
const [mapsKey, setMapsKey] = useState<string>('') const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('') const [weatherKey, setWeatherKey] = useState<string>('')
const [unsplashKey, setUnsplashKey] = useState<string>('')
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({}) const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false) const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({}) const [validating, setValidating] = useState<Record<string, boolean>>({})
@@ -166,6 +167,7 @@ export function useAdmin() {
const data = await authApi.getSettings() const data = await authApi.getSettings()
setMapsKey(data.settings?.maps_api_key || '') setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '') setWeatherKey(data.settings?.openweather_api_key || '')
setUnsplashKey(data.settings?.unsplash_api_key || '')
} catch (err: unknown) { } catch (err: unknown) {
// ignore // ignore
} }
@@ -220,6 +222,7 @@ export function useAdmin() {
await updateApiKeys({ await updateApiKeys({
maps_api_key: mapsKey, maps_api_key: mapsKey,
openweather_api_key: weatherKey, openweather_api_key: weatherKey,
unsplash_api_key: unsplashKey,
}) })
toast.success(t('admin.keySaved')) toast.success(t('admin.keySaved'))
} catch (err: unknown) { } catch (err: unknown) {
@@ -233,7 +236,7 @@ export function useAdmin() {
setValidating({ maps: true, weather: true }) setValidating({ maps: true, weather: true })
try { try {
// Save first so validation uses the current values // Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey }) await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
const result = await authApi.validateKeys() const result = await authApi.validateKeys()
setValidation(result) setValidation(result)
} catch (err: unknown) { } catch (err: unknown) {
@@ -247,7 +250,7 @@ export function useAdmin() {
setValidating(prev => ({ ...prev, [keyType]: true })) setValidating(prev => ({ ...prev, [keyType]: true }))
try { try {
// Save first so validation uses the current values // Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey }) await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
const result = await authApi.validateKeys() const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] })) setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) { } catch (err: unknown) {
@@ -378,7 +381,7 @@ export function useAdmin() {
invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded, smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
showKeys, setShowKeys, savingKeys, validating, validation, showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal, updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt, showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
+12 -2
View File
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { getCached, fetchPhoto } from '../../services/photoService' import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast' import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react'
import { useTranslation, translateApiError } from '../../i18n' import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
@@ -21,6 +21,7 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory' import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection' import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types' import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import { resolvePoolAssignmentId } from './tripPlannerModel' import { resolvePoolAssignmentId } from './tripPlannerModel'
@@ -42,6 +43,9 @@ export function useTripPlanner() {
const toast = useToast() const toast = useToast()
const { t, language } = useTranslation() const { t, language } = useTranslation()
const { settings } = useSettingsStore() const { settings } = useSettingsStore()
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
const allPlugins = usePluginStore(s => s.plugins)
const pluginsLoaded = usePluginStore(s => s.loaded)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const trip = useTripStore(s => s.trip) const trip = useTripStore(s => s.trip)
const days = useTripStore(s => s.days) const days = useTripStore(s => s.days)
@@ -94,6 +98,9 @@ export function useTripPlanner() {
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']) const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const tripPagePlugins = allPlugins.filter(p => p.type === 'trip-page')
const tripPluginIds = tripPagePlugins.map(p => p.id).join(',')
const TRIP_TABS = [ const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map }, { id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train }, { id: 'transports', label: t('trip.tabs.transports'), icon: Train },
@@ -102,6 +109,7 @@ export function useTripPlanner() {
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
...tripPagePlugins.map(p => ({ id: `plugin:${p.id}`, label: p.name, icon: Blocks })),
] ]
const [activeTab, setActiveTab] = useState<string>(() => { const [activeTab, setActiveTab] = useState<string>(() => {
@@ -110,12 +118,14 @@ export function useTripPlanner() {
}) })
useEffect(() => { useEffect(() => {
// Don't evict a saved plugin tab before the plugin feed has loaded.
if (activeTab.startsWith('plugin:') && !pluginsLoaded) return
const validTabIds = TRIP_TABS.map(t => t.id) const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) { if (!validTabIds.includes(activeTab)) {
setActiveTab('plan') setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan') sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
} }
}, [enabledAddons]) }, [enabledAddons, tripPluginIds, pluginsLoaded])
const handleTabChange = (tabId: string): void => { const handleTabChange = (tabId: string): void => {
setActiveTab(tabId) setActiveTab(tabId)
+3 -1
View File
@@ -17,6 +17,7 @@ describe('pluginStore', () => {
plugins: [ plugins: [
{ id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' }, { id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' },
{ id: 'report', name: 'Report', type: 'page', icon: 'FileText' }, { id: 'report', name: 'Report', type: 'page', icon: 'FileText' },
{ id: 'diary', name: 'Diary', type: 'trip-page', icon: 'Book' },
], ],
}), }),
), ),
@@ -26,9 +27,10 @@ describe('pluginStore', () => {
const s = usePluginStore.getState(); const s = usePluginStore.getState();
expect(s.loaded).toBe(true); expect(s.loaded).toBe(true);
expect(s.plugins).toHaveLength(2); expect(s.plugins).toHaveLength(3);
expect(s.pages().map((p) => p.id)).toEqual(['report']); expect(s.pages().map((p) => p.id)).toEqual(['report']);
expect(s.widgets().map((p) => p.id)).toEqual(['flights']); expect(s.widgets().map((p) => p.id)).toEqual(['flights']);
expect(s.tripPages().map((p) => p.id)).toEqual(['diary']);
expect(s.getById('flights')?.name).toBe('Flights'); expect(s.getById('flights')?.name).toBe('Flights');
expect(s.getById('nope')).toBeUndefined(); expect(s.getById('nope')).toBeUndefined();
}); });
+7 -3
View File
@@ -10,9 +10,9 @@ import { pluginsApi } from '../api/client'
export interface ActivePlugin { export interface ActivePlugin {
id: string id: string
name: string name: string
type: 'integration' | 'page' | 'widget' type: 'integration' | 'page' | 'widget' | 'trip-page'
icon: string | null icon: string | null
slot?: 'sidebar' | 'hero' slot?: 'sidebar' | 'hero' | 'place-detail'
} }
interface PluginState { interface PluginState {
@@ -23,6 +23,8 @@ interface PluginState {
pages: () => ActivePlugin[] pages: () => ActivePlugin[]
widgets: () => ActivePlugin[] widgets: () => ActivePlugin[]
heroWidgets: () => ActivePlugin[] heroWidgets: () => ActivePlugin[]
tripPages: () => ActivePlugin[]
placeDetailWidgets: () => ActivePlugin[]
} }
export const usePluginStore = create<PluginState>((set, get) => ({ export const usePluginStore = create<PluginState>((set, get) => ({
@@ -40,6 +42,8 @@ export const usePluginStore = create<PluginState>((set, get) => ({
getById: (id) => get().plugins.find((p) => p.id === id), getById: (id) => get().plugins.find((p) => p.id === id),
pages: () => get().plugins.filter((p) => p.type === 'page'), pages: () => get().plugins.filter((p) => p.type === 'page'),
widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero'), widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail'),
heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'), heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'),
tripPages: () => get().plugins.filter((p) => p.type === 'trip-page'),
placeDetailWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'place-detail'),
})) }))
+18 -7
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react';
import react from '@vitejs/plugin-react' import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -11,7 +11,14 @@ export default defineConfig({
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'], globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html', navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//, /^\/plugin-frame\//], navigateFallbackDenylist: [
/^\/api/,
/^\/uploads/,
/^\/mcp/,
/^\/oauth\//,
/^\/.well-known\//,
/^\/plugin-frame\//,
],
runtimeCaching: [ runtimeCaching: [
{ {
// Carto map tiles (default provider) // Carto map tiles (default provider)
@@ -129,6 +136,10 @@ export default defineConfig({
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
'/plugin-frame': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': { '/uploads': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
@@ -164,6 +175,6 @@ export default defineConfig({
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
} },
} },
}) });
+1
View File
@@ -45,6 +45,7 @@ services:
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset. # - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
+36 -4
View File
@@ -5,11 +5,17 @@ The SDK for building [TREK](https://github.com/mauriceboe/TREK) plugins.
## Scaffold a plugin ## Scaffold a plugin
```bash ```bash
npx trek-plugin-sdk create # interactive wizard (id, type, permissions) npx trek-plugin-sdk # no command? a guided menu of everything below
npx trek-plugin-sdk create # interactive wizard (id, location, type, permissions)
npx trek-plugin-sdk create my-plugin --type widget # or non-interactive npx trek-plugin-sdk create my-plugin --type widget # or non-interactive
cd my-plugin cd my-plugin
``` ```
The wizard also offers to initialize a git repo and install dependencies for you.
In a non-interactive shell (CI, pipes) every command stays flag-driven with plain
output — no prompts, and machine output (`entry` JSON, `pack --json`, PR URLs) stays
on stdout.
## Develop with a live reload loop ## Develop with a live reload loop
`dev` runs your plugin locally — no full TREK needed. It injects a `ctx` that `dev` runs your plugin locally — no full TREK needed. It injects a `ctx` that
@@ -21,9 +27,34 @@ SQLite file, serves your routes and your page/widget UI, and reloads on save.
npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI
``` ```
Hit a route as an unauthenticated request with `?_anon=1`. Drop a Open **`/preview`** to see a page/widget rendered in a real sandboxed frame with a
`dev-fixtures.json` (trips, users, config) next to your manifest to feed theme/accent/appearance toggle (`trek.invoke()` is proxied to your routes). Hit a route
`ctx.trips` / `ctx.users`. as an unauthenticated request with `?_anon=1`. Drop a `dev-fixtures.json` (trips, users,
config) next to your manifest to feed `ctx.trips` / `ctx.users`.
## Build a native UI (page / widget)
The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the
SDK ships it. Put **one line** in your `client/index.html` `<head>`:
```html
<!-- trek:ui -->
```
`dev` and `pack` expand it into the inlined **design kit**: token-driven styles that
follow the host's theme and accent (glass, cards, `.trek-btn`, `.trek-input`,
`.trek-chip`, `.trek-row`, hover), plus a `window.trek` bridge:
```js
trek.onContext((ctx) => { /* ctx.theme, ctx.tokens, ctx.appearance, ctx.user, ctx.tripId */ })
const data = await trek.invoke('/status') // calls your own route, host-proxied
trek.notify('success', 'Saved')
```
The kit applies the theme, mirrors the appearance flags (reduced-motion,
no-transparency) and auto-reports your height. See the
[Plugin Development wiki](https://github.com/mauriceboe/TREK/wiki/Plugin-Development)
for the full component + token reference.
## Write a plugin ## Write a plugin
@@ -99,6 +130,7 @@ losing it means you can't ship signed updates.
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest. - `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
- `validateManifest(json)` — the manifest rules the server loader uses. - `validateManifest(json)` — the manifest rules the server loader uses.
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`). - `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
- `TREK_UI_CSS`, `TREK_THEME_JS`, `TREK_UI_MARKER`, `injectTrekUi(html)` — the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the `<!-- trek:ui -->` marker instead.
## Commands ## Commands
+41
View File
@@ -0,0 +1,41 @@
# Trip Doctor
> A hooks-only example plugin that enriches TREK's own planner — no UI of its own.
Trip Doctor is the companion example for the **provider hooks** and **entity
metadata** added in #1429. It shows how a plugin can contribute *data* that TREK
renders natively, instead of shipping its own iframe widget.
## What it does
- **Warnings** — scans the open trip and flags places with no map location, plus a
tip when the trip has no bookings yet. These appear as a non-blocking banner at
the top of the trip planner (via the `warningProvider` hook).
- **Place notes** — pin a private note on any place; it shows up as a "Note" row at
the foot of the place-detail panel (via the `placeDetailProvider` hook). Notes are
stored in the plugin's own per-plugin metadata (`ctx.meta`), not in a schema fork.
- **`POST /pin`** `{ placeId, note }` — set or clear a place's note. Call it from a
companion widget, a keyboard shortcut, or `curl`.
## Permissions
| Permission | Why |
|---|---|
| `db:read:trips` | Read the trip's places + reservations to find gaps. Membership-checked by the host — the plugin only ever sees a trip the current user can access. |
| `db:meta` | Store/read the plugin's own note on a place. Isolated to this plugin; writes require the user's `place_edit` permission. |
| `hook:trip-warning-provider` | Contribute the validation warnings shown in the planner. |
| `hook:place-detail-provider` | Contribute the "Note" row shown on a place. |
No network access. Every read and write is bound by the host to the person viewing
the planner — Trip Doctor never passes a user id, and a hook that errors or runs
long is dropped without affecting the planner.
## Run it
```bash
npx @trek/plugin-sdk dev # hot-reload against a local TREK
npx @trek/plugin-sdk pack # build a signed .trekplugin bundle
```
See the [Plugin Cookbook](https://github.com/mauriceboe/TREK/wiki/Plugin-Cookbook)
for the individual recipes this example is built from.
@@ -0,0 +1,73 @@
// Trip Doctor — a hooks-only TREK plugin that showcases the #1429 capabilities.
//
// It has no UI of its own. Instead it feeds data into TREK's own planner surfaces
// through two provider hooks, and stores its notes in its own per-plugin metadata:
//
// • warningProvider → validation banner in the trip planner
// • placeDetailProvider → an extra "Note" row at the foot of the place panel
// • POST /pin → pin/clear that note (ctx.meta), from your own UI or a
// keyboard-shortcut plugin
//
// SECURITY: the plugin never passes a user id. The host binds the person viewing
// the planner to the invocation, membership-checks the trip/place against THEM,
// and (for meta writes) requires their place_edit permission. A hook that is slow
// or throws is dropped by the host, so it can never break or stall the planner.
module.exports = {
hooks: {
// Core calls this for the open trip; return the problems to surface.
warningProvider: {
async getWarnings(tripId, ctx) {
const warnings = [];
const places = await ctx.trips.getPlaces(tripId);
for (const p of places) {
if (p.lat == null || p.lng == null) {
warnings.push({
level: 'warning',
message: `"${p.name || 'A place'}" has no map location`,
placeId: p.id,
});
}
}
const reservations = await ctx.trips.getReservations(tripId);
if (reservations.length === 0) {
warnings.push({ level: 'info', message: 'No bookings added to this trip yet' });
}
return warnings;
},
},
// Core calls this for the open place; return the pinned note (if any).
placeDetailProvider: {
async getDetails(placeId, ctx) {
const note = await ctx.meta.get('place', placeId, 'note');
return note ? [{ label: 'Note', value: String(note) }] : [];
},
},
},
routes: [
{
// POST /pin { placeId, note } — pin a note on a place, or clear it (empty note).
method: 'POST',
path: '/pin',
auth: true,
async handler(req, ctx) {
const json = { 'content-type': 'application/json' };
const { placeId, note } = req.body || {};
if (!placeId) {
return { status: 400, headers: json, body: JSON.stringify({ error: 'placeId required' }) };
}
if (note && String(note).trim()) {
await ctx.meta.set('place', Number(placeId), 'note', String(note).trim());
} else {
await ctx.meta.delete('place', Number(placeId), 'note');
}
return { status: 200, headers: json, body: JSON.stringify({ ok: true }) };
},
},
],
};
@@ -0,0 +1,23 @@
{
"id": "trip-doctor",
"name": "Trip Doctor",
"version": "1.0.0",
"apiVersion": 1,
"author": "TREK",
"description": "Checks a trip for common gaps (places with no map location, no bookings yet) and lets you pin a private note on any place. A hooks-only plugin — it has no UI of its own; TREK renders everything natively.",
"homepage": "https://github.com/mauriceboe/trek-plugin-trip-doctor",
"license": "MIT",
"icon": "Stethoscope",
"type": "integration",
"trek": ">=3.2.0 <4.0.0",
"nativeModules": false,
"permissions": [
"db:read:trips",
"db:meta",
"hook:trip-warning-provider",
"hook:place-detail-provider"
],
"routes": [
{ "method": "POST", "path": "/pin", "auth": true }
]
}
+808 -879
View File
File diff suppressed because it is too large Load Diff
+57 -14
View File
@@ -1,25 +1,57 @@
{ {
"name": "trek-plugin-sdk", "name": "trek-plugin-sdk",
"version": "1.2.1", "version": "1.3.0",
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.", "description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "mauriceboe", "author": "mauriceboe",
"maintainers": [
{
"name": "mauriceboe",
"url": "https://github.com/mauriceboe"
},
{
"name": "jubnl",
"url": "https://github.com/jubnl"
}
],
"homepage": "https://github.com/mauriceboe/TREK/wiki/Plugin-Development", "homepage": "https://github.com/mauriceboe/TREK/wiki/Plugin-Development",
"repository": { "type": "git", "url": "git+https://github.com/mauriceboe/TREK.git", "directory": "plugin-sdk" }, "repository": {
"bugs": { "url": "https://github.com/mauriceboe/TREK/issues" }, "type": "git",
"engines": { "node": ">=18" }, "url": "git+https://github.com/mauriceboe/TREK.git",
"publishConfig": { "access": "public" }, "directory": "plugin-sdk"
},
"bugs": {
"url": "https://github.com/mauriceboe/TREK/issues"
},
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "import": {
"require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" } "types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/cjs/index.d.ts",
"default": "./dist/cjs/index.js"
}
}, },
"./testing": { "./testing": {
"import": { "types": "./dist/mock-host.d.ts", "default": "./dist/mock-host.js" }, "import": {
"require": { "types": "./dist/cjs/mock-host.d.ts", "default": "./dist/cjs/mock-host.js" } "types": "./dist/mock-host.d.ts",
"default": "./dist/mock-host.js"
},
"require": {
"types": "./dist/cjs/mock-host.d.ts",
"default": "./dist/cjs/mock-host.js"
}
} }
}, },
"bin": { "bin": {
@@ -27,7 +59,13 @@
"trek-plugin": "./dist/cli/trek-plugin.js", "trek-plugin": "./dist/cli/trek-plugin.js",
"create-trek-plugin": "./dist/cli/create.js" "create-trek-plugin": "./dist/cli/create.js"
}, },
"files": ["dist"], "files": [
"dist"
],
"dependencies": {
"@clack/prompts": "^1.7.0",
"semver": "^7.7.4"
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finish-cjs.mjs", "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finish-cjs.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -35,9 +73,14 @@
"prepublishOnly": "npm run build && npm test" "prepublishOnly": "npm run build && npm test"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.20.0",
"typescript": "^5.6.0", "@types/semver": "^7.7.1",
"vitest": "^2.1.0" "typescript": "^5.9.3",
"vitest": "^4.1.9"
}, },
"keywords": ["trek", "plugin", "sdk"] "keywords": [
"trek",
"plugin",
"sdk"
]
} }
+206 -47
View File
@@ -1,13 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* create-trek-plugin <name> [--type integration|page|widget] (#plugins, M6). * create-trek-plugin <name> [--type integration|page|widget|trip-page] (#plugins, M6).
* Scaffolds a working plugin: manifest, an isolated server entry using * Scaffolds a working plugin: manifest, an isolated server entry using
* definePlugin, a README you must fill in, and (page/widget) a starter iframe. * definePlugin, a README you must fill in, and (page/widget) a starter iframe.
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import readline from 'node:readline'; import os from 'node:os';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import {
intro, outro, note, logSuccess, logWarn, spinner,
promptText, promptSelect, promptMultiselect, promptConfirm,
PERMISSION_CATALOG,
} from './ui.js';
import { KNOWN_ADDONS } from '../manifest.js';
/** This package's own version, for the scaffold's devDependency range. */ /** This package's own version, for the scaffold's devDependency range. */
function sdkVersionRange(): string { function sdkVersionRange(): string {
@@ -23,11 +30,17 @@ export interface ScaffoldOptions {
author?: string; author?: string;
description?: string; description?: string;
permissions?: string[]; permissions?: string[];
/** External hosts the plugin may call — required by the manifest when `http:outbound` is granted. */
egress?: string[];
/** Addon ids that must be enabled for this plugin to activate. */
requiredAddons?: string[];
/** Other plugins this one depends on, each pinned by a semver range. */
pluginDependencies?: Array<{ id: string; version: string }>;
} }
export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void { export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void {
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`); if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
if (!['integration', 'page', 'widget'].includes(type)) throw new Error(`invalid type "${type}"`); if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`);
const root = path.join(targetDir, name); const root = path.join(targetDir, name);
if (fs.existsSync(root)) throw new Error(`${root} already exists`); if (fs.existsSync(root)) throw new Error(`${root} already exists`);
@@ -42,17 +55,23 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
author: opts.author || 'Your Name', author: opts.author || 'Your Name',
description: opts.description || 'Describe what your plugin does.', description: opts.description || 'Describe what your plugin does.',
type, type,
trek: '>=3.2.0 <4.0.0', trek: '>=3.2.1 <4.0.0',
nativeModules: false, nativeModules: false,
permissions: perms, permissions: perms,
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
// that must be enabled to activate; `pluginDependencies` lists other plugins
// ({ id, version-range }) that must be installed + satisfied first.
requiredAddons: opts.requiredAddons ?? [],
pluginDependencies: opts.pluginDependencies ?? [],
routes: [{ method: 'GET', path: '/hello', auth: true }], routes: [{ method: 'GET', path: '/hello', auth: true }],
}; };
if (opts.egress?.length) manifest.egress = opts.egress;
if (type === 'page') manifest.capabilities = { nav: { label: manifest.name, icon: 'Blocks', position: 'main' } }; if (type === 'page') manifest.capabilities = { nav: { label: manifest.name, icon: 'Blocks', position: 'main' } };
if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } }; if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } };
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n'); fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS); fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS(perms.includes('db:own')));
fs.writeFileSync(path.join(root, 'README.md'), readme(name)); fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? '> One sentence: what this plugin does.', perms));
// `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK); // `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK);
// the SDK is a devDependency ONLY (types + mock host) — at runtime both the // the SDK is a devDependency ONLY (types + mock host) — at runtime both the
// dev server and TREK inject it, so it is never vendored into the artifact. // dev server and TREK inject it, so it is never vendored into the artifact.
@@ -70,12 +89,12 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
} }
} }
const SERVER_JS = `// Built plugin entry — runs in an isolated child process. const SERVER_JS = (has_db: boolean) => `// Built plugin entry — runs in an isolated child process.
const { definePlugin } = require('trek-plugin-sdk'); const { definePlugin } = require('trek-plugin-sdk');
module.exports = definePlugin({ module.exports = definePlugin({
async onLoad(ctx) { async onLoad(ctx) {
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)'); ${has_db ? 'await ctx.db.migrate(\'001_init\', \'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)\');' : ''}
ctx.log.info('plugin loaded'); ctx.log.info('plugin loaded');
}, },
routes: [ routes: [
@@ -90,26 +109,57 @@ module.exports = definePlugin({
}); });
`; `;
// The `<!-- trek:ui -->` marker is expanded by `dev` and `pack` into the inlined
// design kit (native styles + a `window.trek` bridge). The source stays this one
// line, so the starter is already themed, glassy and wired on first run.
const CLIENT_HTML = `<!doctype html> const CLIENT_HTML = `<!doctype html>
<html><head><meta charset="utf-8" /><title>Plugin</title></head> <html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Plugin</title>
<!-- trek:ui -->
</head>
<body> <body>
<h1>Hello from your plugin</h1> <div class="trek-glass trek-stack" style="margin: 16px">
<div class="trek-title">Your plugin</div>
<p class="trek-muted" id="hello">Click below to call your /hello route.</p>
<div class="trek-cluster">
<button class="trek-btn trek-btn--primary" id="ping">Say hello</button>
<span class="trek-chip trek-chip--accent" id="who">not connected</span>
</div>
</div>
<script> <script>
// The frame is sandboxed (opaque origin) — talk to TREK only via postMessage. // The design kit is inlined above (window.trek + native styles). The frame is
window.parent.postMessage({ type: 'trek:ready' }, '*'); // sandboxed at an opaque origin — reach TREK only through window.trek.
window.addEventListener('message', (e) => { trek.onContext(function (ctx) {
if (e.data && e.data.type === 'trek:context') { document.getElementById('who').textContent = (ctx.user ? ctx.user.name + ' \\u00b7 ' : '') + ctx.theme;
document.body.dataset.theme = e.data.theme; });
document.getElementById('ping').addEventListener('click', async function () {
try {
var data = await trek.invoke('/hello');
document.getElementById('hello').textContent = 'Hello, ' + ((data && data.hello) || 'traveller') + '!';
} catch (e) {
trek.notify('error', e.message);
} }
}); });
</script> </script>
</body></html> </body>
</html>
`; `;
function readme(name: string): string { /** One markdown table row per granted scope, with the catalog's description as the "Why". */
function permissionRows(scopes: string[]): string {
const rows = (scopes.length ? scopes : ['db:own']).map(
(s) => `| \`${s}\` | 'Describe why this plugin needs it.' |`,
);
return rows.join('\n');
}
function readme(name: string, description: string, scopes: string[]): string {
return `# ${name} return `# ${name}
> One sentence: what this plugin does. ${description}
![screenshot](./docs/screenshot.png) ![screenshot](./docs/screenshot.png)
@@ -127,7 +177,7 @@ looks best (the card crops the edges).
| Permission | Why | | Permission | Why |
|---|---| |---|---|
| \`db:own\` | store the plugin's own data | ${permissionRows(scopes)}
## Setup ## Setup
@@ -140,38 +190,147 @@ one. Replace this line with your license (for example, MIT).
`; `;
} }
const KNOWN_PERMISSIONS = [ const SLUG = /^[a-z][a-z0-9-]{2,39}$/;
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
];
/** Interactive scaffold: prompt for the details, then create the plugin. Returns the chosen name. */ /** Resolve a user-typed directory: expand a leading `~`, then make it absolute. */
export async function interactiveScaffold(targetDir: string, presetName?: string): Promise<string> { function resolveDir(input: string): string {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const raw = (input || '.').trim();
const ask = (q: string, def?: string) => const expanded = raw === '~' || raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(1)) : raw;
new Promise<string>((resolve) => rl.question(def ? `${q} (${def}) ` : `${q} `, (a) => resolve(a.trim() || def || ''))); return path.resolve(expanded);
}
/** True when `dir` sits inside an existing git work tree (so we don't offer to nest a repo). */
function insideGitRepo(dir: string): boolean {
try { try {
let name = presetName || ''; execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, stdio: 'ignore' });
while (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) { return true;
name = await ask('Plugin id (lowercase-slug):'); } catch {
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) console.log(' → must be a lowercase slug, 3–40 chars (e.g. flight-tracker)'); return false;
}
let type = '';
while (!['integration', 'page', 'widget'].includes(type)) {
type = (await ask('Type — integration | page | widget:', 'integration')).toLowerCase();
}
const author = await ask('Author:', 'Your Name');
const description = await ask('One-line description:', 'Describe what your plugin does.');
console.log(`\n Permissions (space/comma separated). Available:\n ${KNOWN_PERMISSIONS.join(', ')}`);
const permsRaw = await ask('Permissions:', 'db:own');
const permissions = permsRaw.split(/[\s,]+/).filter(Boolean).filter((p) => KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:'));
scaffold(name, type, targetDir, { author, description, permissions });
return name;
} finally {
rl.close();
} }
} }
/**
* Interactive scaffold: a Clack wizard that collects the details, writes the
* plugin, and offers to set up git + install deps. Returns the created plugin id.
* Only ever called when stdout is a TTY (the dispatcher guards this).
*/
export async function interactiveScaffold(defaultDir: string, presetName?: string): Promise<string> {
intro('create-trek-plugin');
const id = presetName && SLUG.test(presetName)
? presetName
: await promptText({
message: 'Plugin id',
placeholder: 'flight-tracker',
initialValue: presetName ?? '',
validate: (v) => (SLUG.test((v ?? '').trim()) ? undefined : 'lowercase slug, 3–40 chars (e.g. flight-tracker)'),
}).then((v) => v.trim());
const location = await promptText({
message: 'Where should the plugin be created?',
placeholder: `. (creates ./${id}/ here)`,
defaultValue: defaultDir,
validate: (v) => (fs.existsSync(path.join(resolveDir(v || defaultDir), id))
? `${path.join(v || '.', id)} already exists`
: undefined),
});
const parentDir = resolveDir(location || defaultDir);
const dest = path.join(parentDir, id);
const type = await promptSelect<string>({
message: 'What kind of plugin is this?',
initialValue: 'integration',
options: [
{ value: 'integration', label: 'integration', hint: 'server-only: routes, hooks, background work' },
{ value: 'page', label: 'page', hint: 'adds a full navigation page (sandboxed iframe UI)' },
{ value: 'widget', label: 'widget', hint: 'adds a dashboard widget (sandboxed iframe UI)' },
{ value: 'trip-page', label: 'trip-page', hint: 'adds a tab inside every trip (sandboxed iframe UI)' },
],
});
const author = await promptText({ message: 'Author', placeholder: 'Your Name', defaultValue: 'Your Name' });
const description = await promptText({
message: 'One-line description',
placeholder: 'Describe what your plugin does.',
defaultValue: 'Describe what your plugin does.',
});
const permissions = await promptMultiselect<string>({
message: 'Which permissions does it need?',
options: PERMISSION_CATALOG.map((p) => ({ value: p.value, label: p.label, hint: p.hint })),
initialValues: ['db:own'],
required: false,
});
let egress: string[] | undefined;
if (permissions.includes('http:outbound')) {
const raw = await promptText({
message: 'External hosts it may call (comma-separated)',
placeholder: 'api.example.com, api.other.com',
validate: (v) => ((v ?? '').split(',').map((s) => s.trim()).filter(Boolean).length ? undefined : 'list at least one host'),
});
egress = raw.split(',').map((s) => s.trim()).filter(Boolean);
}
const requiredAddons = await promptMultiselect<string>({
message: 'Requires any TREK addons enabled? (optional — the plugin can only activate when these are on)',
options: KNOWN_ADDONS.map((a) => ({ value: a, label: a })),
initialValues: [],
required: false,
});
note(
[
`id ${id}`,
`type ${type}`,
`location ${dest}`,
`author ${author}`,
`permissions ${permissions.join(', ') || '(none)'}`,
egress ? `egress ${egress.join(', ')}` : undefined,
requiredAddons.length ? `addons ${requiredAddons.join(', ')}` : undefined,
].filter(Boolean).join('\n'),
'Review',
);
const confirmed = await promptConfirm({ message: `Create the plugin at ${dest}?`, initialValue: true });
if (!confirmed) {
outro('Cancelled — nothing was written.');
process.exit(0);
}
scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons });
logSuccess(`Created ${dest}`);
if (!insideGitRepo(parentDir)) {
const doGit = await promptConfirm({ message: 'Initialize a git repository?', initialValue: true });
if (doGit) {
try {
execFileSync('git', ['init'], { cwd: dest, stdio: 'ignore' });
logSuccess('Initialized a git repository');
} catch {
logWarn('Could not initialize git — run `git init` yourself later.');
}
}
}
const doInstall = await promptConfirm({ message: 'Install dependencies now?', initialValue: true });
if (doInstall) {
const s = spinner();
s.start('Installing dependencies');
try {
execFileSync('npm', ['install'], { cwd: dest, stdio: 'ignore' });
s.stop('Dependencies installed');
} catch {
s.stop('Could not install dependencies');
logWarn('Run `npm install` in the plugin directory later.');
}
}
const cd = path.relative(process.cwd(), dest) || dest;
outro(`Next steps:\n cd ${cd}\n npx trek-plugin-sdk dev`);
return id;
}
// CLI entry // CLI entry
if (process.argv[1] && process.argv[1].endsWith('create.js')) { if (process.argv[1] && process.argv[1].endsWith('create.js')) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -179,7 +338,7 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) {
const typeIdx = args.indexOf('--type'); const typeIdx = args.indexOf('--type');
const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration'; const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration';
if (!name) { if (!name) {
console.error('usage: create-trek-plugin <name> [--type integration|page|widget]'); console.error('usage: create-trek-plugin <name> [--type integration|page|widget|trip-page]');
process.exit(2); process.exit(2);
} }
try { try {
+106 -5
View File
@@ -14,6 +14,7 @@ import path from 'node:path';
import http from 'node:http'; import http from 'node:http';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import * as sdk from '../index.js'; import * as sdk from '../index.js';
import { injectTrekUi } from '../ui/kit.js';
import { readJsonFile } from './json.js'; import { readJsonFile } from './json.js';
interface Fixtures { interface Fixtures {
@@ -201,6 +202,14 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
if (url.pathname === '/__dev/version') return send(200, String(version)); if (url.pathname === '/__dev/version') return send(200, String(version));
if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length), 'text/html; charset=utf-8'); if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length), 'text/html; charset=utf-8');
// Faithful host preview: embeds /ui in a sandboxed opaque-origin iframe (exactly
// like TREK) and plays the host — posts trek:context (with a theme/accent toggle),
// proxies trek:invoke to your /api routes, and honours resize/notify/navigate. This
// is where the design kit actually renders themed.
if (url.pathname === '/preview' && String(manifest.type) !== 'integration') {
return send(200, preview(id, String(manifest.type)), 'text/html; charset=utf-8');
}
// Static plugin UI at /ui (page/widget client bundle) // Static plugin UI at /ui (page/widget client bundle)
if (url.pathname === '/ui' || url.pathname.startsWith('/ui/')) { if (url.pathname === '/ui' || url.pathname.startsWith('/ui/')) {
const relFile = url.pathname === '/ui' ? 'index.html' : url.pathname.slice('/ui/'.length); const relFile = url.pathname === '/ui' ? 'index.html' : url.pathname.slice('/ui/'.length);
@@ -209,10 +218,11 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'not found — build your client/ bundle'); if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'not found — build your client/ bundle');
const raw = fs.readFileSync(file); const raw = fs.readFileSync(file);
const type = contentType(file); const type = contentType(file);
// Only the HTML doc is rewritten (live-reload inject) as a string; every other // Only the HTML doc is rewritten (expand the `<!-- trek:ui -->` marker into the
// asset is sent as the raw Buffer — a UTF-8 round-trip corrupts binary files. // design kit, then inject live-reload) as a string; every other asset is sent as
// the raw Buffer — a UTF-8 round-trip corrupts binary files.
const body: string | Buffer = type.startsWith('text/html') const body: string | Buffer = type.startsWith('text/html')
? String(raw).replace('</body>', `${LIVE_RELOAD}</body>`) ? injectTrekUi(String(raw)).replace('</body>', `${LIVE_RELOAD}</body>`)
: raw; : raw;
return send(200, body, type); return send(200, body, type);
} }
@@ -250,7 +260,10 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
console.log(` ${dbNote}`); console.log(` ${dbNote}`);
console.log(` granted: ${[...grants].join(', ') || '(none)'}`); console.log(` granted: ${[...grants].join(', ') || '(none)'}`);
console.log(`\n ▸ http://localhost:${port}/ dashboard`); console.log(`\n ▸ http://localhost:${port}/ dashboard`);
if (String(manifest.type) !== 'integration') console.log(` ▸ http://localhost:${port}/ui your plugin UI`); if (String(manifest.type) !== 'integration') {
console.log(` ▸ http://localhost:${port}/preview themed host preview`);
console.log(` ▸ http://localhost:${port}/ui your plugin UI (raw)`);
}
for (const r of routes) console.log(` ▸ ${r.method.padEnd(4)} http://localhost:${port}/api${r.path}${r.auth === false ? ' (public)' : ''}`); for (const r of routes) console.log(` ▸ ${r.method.padEnd(4)} http://localhost:${port}/api${r.path}${r.auth === false ? ' (public)' : ''}`);
console.log('\n editing server/ or client/ hot-reloads. Ctrl+C to stop.\n'); console.log('\n editing server/ or client/ hot-reloads. Ctrl+C to stop.\n');
@@ -284,7 +297,9 @@ function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote:
const rows = routes.length const rows = routes.length
? routes.map((r) => `<tr><td><code>${r.method}</code></td><td><a href="/api${r.path}">/api${r.path}</a></td><td>${r.auth === false ? 'public' : 'auth'}</td></tr>`).join('') ? routes.map((r) => `<tr><td><code>${r.method}</code></td><td><a href="/api${r.path}">/api${r.path}</a></td><td>${r.auth === false ? 'public' : 'auth'}</td></tr>`).join('')
: '<tr><td colspan="3" style="opacity:.6">no routes declared</td></tr>'; : '<tr><td colspan="3" style="opacity:.6">no routes declared</td></tr>';
const ui = type !== 'integration' ? `<p><a href="/ui">▸ open your plugin UI (/ui)</a></p>` : ''; const ui = type !== 'integration'
? `<p><a href="/preview">▸ open the themed host preview (/preview)</a> &nbsp;·&nbsp; <a href="/ui">raw /ui</a></p>`
: '';
return `<!doctype html><meta charset="utf-8"><title>${id} · trek-plugin dev</title> return `<!doctype html><meta charset="utf-8"><title>${id} · trek-plugin dev</title>
<style>body{font:15px/1.5 system-ui,sans-serif;max-width:720px;margin:3rem auto;padding:0 1rem;color:#111} <style>body{font:15px/1.5 system-ui,sans-serif;max-width:720px;margin:3rem auto;padding:0 1rem;color:#111}
h1{font-size:1.4rem}code{background:#f3f4f6;padding:.1em .35em;border-radius:4px} h1{font-size:1.4rem}code{background:#f3f4f6;padding:.1em .35em;border-radius:4px}
@@ -297,3 +312,89 @@ ${ui}
<p class="muted">Add <code>?_anon=1</code> to a route to hit it as an unauthenticated request. Edit <code>server/</code> or <code>client/</code> and this reloads.</p> <p class="muted">Add <code>?_anon=1</code> to a route to hit it as an unauthenticated request. Edit <code>server/</code> or <code>client/</code> and this reloads.</p>
${LIVE_RELOAD}`; ${LIVE_RELOAD}`;
} }
/**
* A faithful host preview for a page/widget plugin. Loads /ui in a sandboxed,
* opaque-origin iframe (no allow-same-origin — exactly TREK's isolation) and plays
* the host over postMessage: it sends trek:context (with a theme/accent/appearance
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
*/
function preview(id: string, type: string): string {
const maxW = type === 'widget' ? '440px' : '1000px';
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${id} · preview</title>
<style>
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 system-ui,-apple-system,sans-serif;background:#f4f5f7;color:#111827}
body.dark{background:#0e0e11;color:#e5e7eb}
header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;background:inherit;z-index:2}
body.dark header{border-color:#27272a}
header strong{font-weight:600}.sp{flex:1}
label{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;opacity:.85}
select,button{font:inherit;font-size:13px;padding:5px 10px;border-radius:8px;border:1px solid #d1d5db;background:#fff;color:inherit;cursor:pointer}
body.dark select,body.dark button{background:#1c1c21;border-color:#3f3f46;color:#e5e7eb}
.stage{padding:28px 20px;display:flex;justify-content:center}
.wrap{width:100%;max-width:${maxW}}
iframe{width:100%;border:0;background:transparent;min-height:120px;display:block}
.hint{font-size:12px;opacity:.55;text-align:center;padding:0 16px 20px}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#111827;color:#fff;padding:9px 16px;border-radius:10px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:3}
.toast.on{opacity:.96}
</style></head>
<body>
<header>
<strong>${id}</strong><span style="opacity:.5">· ${type} preview</span>
<span class="sp"></span>
<label>Theme <select id="theme"><option value="light">light</option><option value="dark">dark</option></select></label>
<label>Accent <select id="accent"><option value="default">default</option><option value="indigo">indigo</option><option value="teal">teal</option><option value="rose">rose</option></select></label>
<label><input type="checkbox" id="rm"> reduce motion</label>
<label><input type="checkbox" id="nt"> no transparency</label>
<label><input type="checkbox" id="trip" checked> trip context</label>
</header>
<div class="stage"><div class="wrap"><iframe id="f" src="/ui" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" title="${id}"></iframe></div></div>
<p class="hint">The frame runs sandboxed at an opaque origin, exactly like TREK — <code>trek.invoke()</code> is proxied to your /api routes as the dev user.</p>
<div class="toast" id="toast"></div>
<script>
var f=document.getElementById('f');
var LA={"default":{"--accent":"#111827","--accent-text":"#fff","--accent-hover":"#1f2937","--accent-subtle":"#f1f5f9"},
indigo:{"--accent":"#4f46e5","--accent-text":"#fff","--accent-hover":"#4338ca","--accent-subtle":"#eef2ff"},
teal:{"--accent":"#0d9488","--accent-text":"#fff","--accent-hover":"#0f766e","--accent-subtle":"#f0fdfa"},
rose:{"--accent":"#e11d48","--accent-text":"#fff","--accent-hover":"#be123c","--accent-subtle":"#fff1f2"}};
var DA={"default":{"--accent":"#e4e4e7","--accent-text":"#09090b","--accent-hover":"#d4d4d8","--accent-subtle":"rgba(255,255,255,.08)"},
indigo:{"--accent":"#818cf8","--accent-text":"#09090b","--accent-hover":"#a5b4fc","--accent-subtle":"rgba(129,140,248,.18)"},
teal:{"--accent":"#2dd4bf","--accent-text":"#09090b","--accent-hover":"#5eead4","--accent-subtle":"rgba(45,212,191,.18)"},
rose:{"--accent":"#fb7185","--accent-text":"#09090b","--accent-hover":"#fda4af","--accent-subtle":"rgba(251,113,133,.18)"}};
function val(id){return document.getElementById(id);}
function ctx(){
var theme=val("theme").value, accent=val("accent").value;
document.body.classList.toggle("dark", theme==="dark");
var tokens={}; var src=theme==="dark"?DA[accent]:LA[accent]; for(var k in src){tokens[k]=src[k];}
return {type:"trek:context",theme:theme,locale:"en",hostOrigin:location.origin,
tripId: val("trip").checked?42:null, userId:"1",
user:{name:"Dev User",avatar:null,isAdmin:true},
appearance:{scheme:accent,density:"comfortable",reducedMotion:val("rm").checked,noTransparency:val("nt").checked},
formats:{locale:"en",currency:"EUR",timeFormat:"24h",distanceUnit:"metric",temperatureUnit:"celsius",timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},
tokens:tokens};
}
function postCtx(){ if(f.contentWindow) f.contentWindow.postMessage(ctx(),"*"); }
var tt; function toast(msg){var el=document.getElementById("toast");el.textContent=msg;el.classList.add("on");clearTimeout(tt);tt=setTimeout(function(){el.classList.remove("on");},2200);}
window.addEventListener("message", function(ev){
if(ev.source!==f.contentWindow) return;
var m=ev.data; if(!m||typeof m!=="object") return;
if(m.type==="trek:ready"||m.type==="trek:context:request"){ postCtx(); }
else if(m.type==="trek:resize"){ if(m.height>0) f.style.height=Math.min(m.height,2000)+"px"; }
else if(m.type==="trek:notify"){ toast((m.level?("["+m.level+"] "):"")+(m.message||"")); }
else if(m.type==="trek:navigate"){ toast("navigate \\u2192 "+m.to); }
else if(m.type==="trek:invoke"){
fetch("/api"+m.sub,{method:m.method||"GET",headers:{"content-type":"application/json"},body:m.body!=null?JSON.stringify(m.body):undefined})
.then(function(r){var ct=r.headers.get("content-type")||"";return (ct.indexOf("json")>=0?r.json():r.text());})
.then(function(data){ f.contentWindow.postMessage({type:"trek:response",requestId:m.requestId,data:data},"*"); })
.catch(function(e){ f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:"error",message:String(e&&e.message||e)},"*"); });
}
});
["theme","accent","rm","nt","trip"].forEach(function(id){ val(id).addEventListener("change",postCtx); });
f.addEventListener("load", function(){ f.style.height="120px"; postCtx(); });
var __v; setInterval(function(){ fetch("/__dev/version").then(function(r){return r.text();}).then(function(v){ if(__v&&v!==__v){ f.src=f.src; } __v=v; }).catch(function(){}); },1000);
</script>
</body></html>`;
}
+70
View File
@@ -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<string | null> {
intro('trek-plugin-sdk');
for (;;) {
const choice = await promptSelect<string>({
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<string>({ message: 'Advanced commands', options: toOptions(ADVANCED_MENU) });
if (adv === 'back') continue;
return adv;
}
return choice;
}
}
+9 -1
View File
@@ -12,6 +12,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { makeZip, type ZipInput } from '../zip.js'; import { makeZip, type ZipInput } from '../zip.js';
import { injectTrekUi } from '../ui/kit.js';
import { validatePluginDir } from './validate.js'; import { validatePluginDir } from './validate.js';
const MAX_TOTAL = 50 * 1024 * 1024; const MAX_TOTAL = 50 * 1024 * 1024;
@@ -41,7 +42,14 @@ function walk(base: string, rel: string, out: ZipInput[]): void {
walk(base, childRel, out); walk(base, childRel, out);
} else if (entry.isFile()) { } else if (entry.isFile()) {
if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue; if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue;
out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) }); // Expand the `<!-- trek:ui -->` 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) // symlinks and other non-regular entries are skipped (never shipped)
} }
+11 -7
View File
@@ -14,6 +14,7 @@ import { packPluginDir } from './pack.js';
import { buildEntry } from './entry.js'; import { buildEntry } from './entry.js';
import { preflight } from './preflight.js'; import { preflight } from './preflight.js';
import { submitEntry } from './submit.js'; import { submitEntry } from './submit.js';
import { plainLog, type LogSink } from './ui.js';
function git(dir: string, args: string[], quiet = true): string { function git(dir: string, args: string[], quiet = true): string {
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' }).toString().trim(); 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: { export async function publishPlugin(opts: {
dir: string; repo: string; tag: string; now: string; dir: string; repo: string; tag: string; now: string;
signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean; 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 }> { }): Promise<{ prUrl: string }> {
const dir = path.resolve(opts.dir); 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 // 1. Pack
step(1, 'Packing the artifact…'); step(1, 'Packing the artifact…');
const zip = path.join(dir, 'plugin.zip'); const zip = path.join(dir, 'plugin.zip');
const packed = packPluginDir(dir, 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 // 2. Tag (if needed) + push + GitHub release with the artifact attached
step(2, `Tagging ${opts.tag} + creating the GitHub release…`); 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. // 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' }); 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 // 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 }); const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
if (opts.skipPreflight) { if (opts.skipPreflight) {
console.error('[3/4] Preflight skipped (--no-preflight).'); log('[3/4] Preflight skipped (--no-preflight).');
} else { } else {
step(3, 'Preflight — running the registry CI checks…'); step(3, 'Preflight — running the registry CI checks…');
const rep = await preflight(entry); 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?`); 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 // 4. Open the registry PR
step(4, 'Opening the registry PR…'); step(4, 'Opening the registry PR…');
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft }); const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
console.error(' ✓ done'); log(' ✓ done');
fs.rmSync(zip, { force: true }); fs.rmSync(zip, { force: true });
return { prUrl }; return { prUrl };
} }
+184 -85
View File
@@ -34,6 +34,11 @@ import { submitEntry } from './submit.js';
import { publishPlugin } from './publish.js'; import { publishPlugin } from './publish.js';
import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js'; import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js';
import { readJsonFile } from './json.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); const [cmd, ...args] = process.argv.slice(2);
@@ -57,116 +62,210 @@ function fail(msg: string): never {
const { flags, pos } = parse(args); const { flags, pos } = parse(args);
type Flags = Record<string, string>;
/** --sign, --sign <keyfile>, or absent → the key path to sign with (or undefined). */ /** --sign, --sign <keyfile>, or absent → the key path to sign with (or undefined). */
function signKey(): string | undefined { function signKey(f: Flags): string | undefined {
if (!flags.sign) return undefined; if (!f.sign) return undefined;
return flags.sign === 'true' ? (flags.key || defaultKeyPath()) : flags.sign; 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 <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release|publish> [...]';
async function main(): Promise<void> { async function main(): Promise<void> {
if (cmd === 'create') { if (!cmd) {
const name = pos[0]; // Bare invocation: a menu in a terminal, the usage line for scripts.
if (!name || flags.interactive) { if (!isInteractive()) { console.error(USAGE); process.exit(2); }
const created = await interactiveScaffold(process.cwd(), name); const chosen = await runMenu();
console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${created}\`.`); if (chosen) await dispatch(chosen, {}, []);
return;
}
await dispatch(cmd, flags, pos);
}
async function dispatch(command: string, f: Flags, positional: string[]): Promise<void> {
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 <name> [--type integration|page|widget]');
await interactiveScaffold(process.cwd(), name);
return; return;
} }
scaffold(name, flags.type || 'integration', process.cwd(), { scaffold(name, f.type || 'integration', process.cwd(), {
author: flags.author, description: flags.description, author: f.author, description: f.description,
permissions: flags.permissions ? flags.permissions.split(/[\s,]+/).filter(Boolean) : undefined, 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}\`.`); console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`);
} else if (cmd === 'dev') { } else if (command === 'dev') {
await runDev(pos[0] || '.', { port: flags.port ? Number(flags.port) : undefined }); if (tui) intro(`trek-plugin dev — ${positional[0] || '.'}`);
} else if (cmd === 'validate') { await runDev(positional[0] || '.', { port: f.port ? Number(f.port) : undefined });
const r = validatePluginDir(pos[0] || '.'); } else if (command === 'validate') {
for (const w of r.warnings) console.warn('warning: ' + w); const r = validatePluginDir(positional[0] || '.');
if (!r.ok) { for (const e of r.errors) console.error('error: ' + e); process.exit(1); } if (tui) {
console.log('✓ plugin is valid'); for (const w of r.warnings) logWarn(w);
} else if (cmd === 'pack') { if (!r.ok) { for (const e of r.errors) logError(e); outro('✗ plugin has errors'); process.exit(1); }
const r = packPluginDir(pos[0] || '.', flags.out || 'plugin.zip'); outro('✓ plugin is valid');
if (flags.json) {
console.log(JSON.stringify(r, null, 2));
} else { } else {
console.log(`Packed ${r.files.length} files -> ${path.relative(process.cwd(), r.artifact) || r.artifact}`); for (const w of r.warnings) console.warn('warning: ' + w);
for (const f of r.files) console.log(' ' + f); 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(`\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.'); 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') { } else if (command === 'keygen') {
const keyPath = flags.key || defaultKeyPath(); const keyPath = f.key || defaultKeyPath();
const { publicKey } = generateKeypair(keyPath); 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).`); if (tui) {
console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); 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');
console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); logInfo('Sign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).');
} else if (cmd === 'sign') { } else {
const zip = pos[0] || 'plugin.zip'; 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`); 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); const buf = fs.readFileSync(zip);
console.log(`signature: ${signArtifact(buf, key)}`); if (tui) {
console.log(`authorPublicKey: ${publicKeyBase64(key)}`); note(`signature: ${signArtifact(buf, key)}\nauthorPublicKey: ${publicKeyBase64(key)}`, `Signed ${zip}`);
} else if (cmd === 'entry') { } else {
if (!flags.repo || !flags.tag) fail('entry needs --repo <owner/name> and --tag <vX.Y.Z>'); console.log(`signature: ${signArtifact(buf, key)}`);
console.log(`authorPublicKey: ${publicKeyBase64(key)}`);
}
} else if (command === 'entry') {
const { repo, tag } = await ensureRepoTag(f, 'entry needs --repo <owner/name> and --tag <vX.Y.Z>');
const entry = buildEntry({ const entry = buildEntry({
dir: flags.dir || pos[0] || '.', dir: f.dir || positional[0] || '.', repo, tag,
repo: flags.repo, tag: flags.tag, zipPath: f.zip || 'plugin.zip',
zipPath: flags.zip || 'plugin.zip', commit: f.commit, asset: f.asset, mergePath: f.merge,
commit: flags.commit, asset: flags.asset, mergePath: flags.merge, signKeyPath: signKey(f), now: new Date().toISOString(),
signKeyPath: signKey(),
now: new Date().toISOString(),
}); });
const json = JSON.stringify(entry, null, 2) + '\n'; const json = JSON.stringify(entry, null, 2) + '\n';
if (flags.out) { if (f.out) {
fs.writeFileSync(flags.out, json); fs.writeFileSync(f.out, json);
console.error(`Wrote ${flags.out} — add it as registry/plugins/${entry.id}.json in a TREK-Plugins PR.`); 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 { } else {
process.stdout.write(json); process.stdout.write(json); // machine output on stdout — never decorated
}
} else if (command === 'preflight') {
let entry: ReturnType<typeof buildEntry>;
if (f.entry) {
entry = readJsonFile<ReturnType<typeof buildEntry>>(f.entry);
} else {
const { repo, tag } = await ensureRepoTag(f, 'preflight needs --repo <owner/name> --tag <vX>, or --entry <file.json>');
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 <owner/name> and --tag <vX.Y.Z>');
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<ReturnType<typeof buildEntry>>(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 <owner/name> --tag <vX>, or --entry <file.json>'));
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 <owner/name> and --tag <vX.Y.Z>');
const { prUrl } = await publishPlugin({ const { prUrl } = await publishPlugin({
dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, dir: positional[0] || '.', repo, tag,
signKeyPath: signKey(), registry: flags.registry, draft: !!flags.draft, signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft,
notes: flags.notes, skipPreflight: !!flags['no-preflight'], now: new Date().toISOString(), notes: f.notes, skipPreflight: !!f['no-preflight'], now: new Date().toISOString(),
log: tui ? clackLogSink : undefined,
}); });
console.error('\n✓ published — registry PR:'); if (tui) logSuccess('Published — registry PR:'); else console.error('\n✓ published — registry PR:');
console.log(prUrl); console.log(prUrl); // machine output on stdout
} else if (cmd === 'submit') { } else if (command === 'submit') {
if (!flags.repo || !flags.tag) fail('submit needs --repo <owner/name> and --tag <vX.Y.Z>'); const { repo, tag } = await ensureRepoTag(f, 'submit needs --repo <owner/name> and --tag <vX.Y.Z>');
const entry = buildEntry({ const entry = buildEntry({
dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, dir: positional[0] || '.', repo, tag,
zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), zipPath: f.zip || 'plugin.zip', commit: f.commit, signKeyPath: signKey(f), now: new Date().toISOString(),
now: new Date().toISOString(),
}); });
console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`); if (tui) {
const { prUrl } = submitEntry(entry, { registry: flags.registry, branch: flags.branch, draft: !!flags.draft, keep: !!flags.keep }); note(`${entry.id} ${entry.versions[0].version}\nrepo ${repo}`, 'Submit registry PR');
console.log(prUrl); const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true });
} else if (cmd === 'release') { if (!ok) { outro('Cancelled — no PR opened.'); return; }
if (!flags.repo || !flags.tag) fail('release needs --repo <owner/name> and --tag <vX.Y.Z>'); const s = spinner(); s.start('Opening the registry PR');
const dir = pos[0] || '.'; const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
const zip = path.resolve(dir, flags.out || 'plugin.zip'); s.stop('Registry PR opened');
const packed = packPluginDir(dir, zip); console.log(prUrl);
console.error(`Packed ${packed.files.length} files (${packed.size} bytes).`); } else {
console.error(`Creating GitHub release ${flags.tag} on ${flags.repo}…`); console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`);
execFileSync('gh', ['release', 'create', flags.tag, packed.artifact, '--repo', flags.repo, '--title', flags.tag, '--notes', flags.notes || `Release ${flags.tag}`], { stdio: 'inherit' }); const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
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.log(prUrl);
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 if (command === 'release') {
const { repo, tag } = await ensureRepoTag(f, 'release needs --repo <owner/name> and --tag <vX.Y.Z>');
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 { } else {
console.error('usage: trek-plugin <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release|publish> [...]'); console.error(USAGE);
process.exit(2); process.exit(2);
} }
} }
+122
View File
@@ -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<T>(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<TextOptions, 'output'>): Promise<string> =>
orCancel(await clackText({ ...opts, output: OUT }));
export const promptSelect = async <Value>(opts: Omit<SelectOptions<Value>, 'output'>): Promise<Value> =>
orCancel(await clackSelect({ ...opts, output: OUT }));
export const promptMultiselect = async <Value>(opts: Omit<MultiSelectOptions<Value>, 'output'>): Promise<Value[]> =>
orCancel(await clackMultiselect({ ...opts, output: OUT }));
export const promptConfirm = async (opts: Omit<ConfirmOptions, 'output'>): Promise<boolean> =>
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<string, string>, required: string[]): string[] {
return required.filter((k) => !flags[k]);
}
+6 -1
View File
@@ -7,7 +7,7 @@
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { validateManifest } from '../manifest.js'; import { validateManifest, KNOWN_ADDONS } from '../manifest.js';
import { readJsonFile } from './json.js'; import { readJsonFile } from './json.js';
export interface ValidateReport { export interface ValidateReport {
@@ -33,6 +33,11 @@ export function validatePluginDir(dir: string): ValidateReport {
if (manifestId && path.basename(path.resolve(dir)) !== manifestId) { if (manifestId && path.basename(path.resolve(dir)) !== manifestId) {
warnings.push(`directory name should equal the plugin id "${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) { } catch (e) {
errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e)); errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e));
} }
+125 -6
View File
@@ -9,6 +9,20 @@
/** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */ /** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */
export const PLUGIN_API_VERSION = 1 as const; 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 { export interface PluginContext {
readonly id: string; readonly id: string;
readonly config: Readonly<Record<string, unknown>>; readonly config: Readonly<Record<string, unknown>>;
@@ -18,11 +32,58 @@ export interface PluginContext {
migrate(id: string, sql: string): Promise<{ applied: boolean }>; migrate(id: string, sql: string): Promise<{ applied: boolean }>;
}; };
trips: { trips: {
getById(tripId: number, asUserId: number): Promise<unknown>; getById(tripId: number, asUserId?: number): Promise<Trip | null>;
getPlaces(tripId: number, asUserId: number): Promise<unknown[]>; getPlaces(tripId: number, asUserId?: number): Promise<Place[]>;
getReservations(tripId: number, asUserId: number): Promise<unknown[]>; getReservations(tripId: number, asUserId?: number): Promise<Reservation[]>;
/** Update trip fields; needs `db:write:trips` + the acting user's trip_edit permission. Route context only. */
update(tripId: number, input: Record<string, unknown>): Promise<Trip>;
}; };
users: { getById(id: number): Promise<unknown> }; // 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<PackingItem[]>;
};
files: {
/** A trip's files, trash excluded. Needs `db:read:files`. */
list(tripId: number): Promise<TripFile[]>;
};
// "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<BudgetItem[]>;
listMine(): Promise<BudgetItem[]>;
create(tripId: number, input: Record<string, unknown>): Promise<BudgetItem>;
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<BudgetItem>;
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<string, unknown>): Promise<Place>;
update(tripId: number, placeId: number, input: Record<string, unknown>): Promise<Place>;
delete(tripId: number, placeId: number): Promise<{ deleted: boolean }>;
};
days: {
create(tripId: number, input: Record<string, unknown>): Promise<Day>;
update(tripId: number, dayId: number, input: Record<string, unknown>): Promise<Day>;
delete(tripId: number, dayId: number): Promise<{ deleted: boolean }>;
};
itinerary: {
assign(tripId: number, dayId: number, placeId: number, notes?: string | null): Promise<Assignment>;
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<unknown>;
set(entityType: 'trip' | 'place' | 'day', entityId: number, key: string, value: unknown): Promise<unknown>;
list(entityType: 'trip' | 'place' | 'day', entityId: number): Promise<Record<string, unknown>>;
delete(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<{ deleted: boolean }>;
};
users: { getById(id: number): Promise<User | null> };
ws: { ws: {
broadcastToTrip(tripId: number, event: string, data: Record<string, unknown>): Promise<void>; broadcastToTrip(tripId: number, event: string, data: Record<string, unknown>): Promise<void>;
broadcastToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void>; broadcastToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void>;
@@ -32,6 +93,17 @@ export interface PluginContext {
warn(msg: string, meta?: Record<string, unknown>): void; warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void; error(msg: string, meta?: Record<string, unknown>): 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<unknown>;
};
/** 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 { export interface PluginRequest {
@@ -81,7 +153,40 @@ export interface CalendarEvent {
} }
export interface CalendarSource { export interface CalendarSource {
getName(): string; getName(): string;
getEvents(userId: number, start: Date, end: Date): Promise<CalendarEvent[]>; // 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<CalendarEvent[]>;
}
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
export interface PlaceDetailProvider {
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. */
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
export interface WarningProvider {
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. */
getWarnings(tripId: number, ctx: PluginContext): Promise<TripWarning[]>;
}
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job)
* and receive only the event name + tripId — never the payload. Needs `events:subscribe`. */
export interface PluginEventSubscription {
/** 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> | void;
}
/** A function this plugin exposes to its dependents (declared in `capabilities.provides`). */
export type PluginExport = (args: unknown, ctx: PluginContext) => Promise<unknown> | unknown;
/** A subscription to another plugin's event. Authorized by declaring that plugin as a
* `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> | void;
} }
export interface PluginDefinition { export interface PluginDefinition {
@@ -89,7 +194,17 @@ export interface PluginDefinition {
onUnload?(ctx: PluginContext): Promise<void> | void; onUnload?(ctx: PluginContext): Promise<void> | void;
routes?: PluginRoute[]; routes?: PluginRoute[];
jobs?: PluginJob[]; 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<string, PluginExport>;
/** 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. */ /** 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 { validateManifest, type PluginManifest, type ValidationResult } from './manifest.js';
export { createMockHost, type MockHostOptions } from './mock-host.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 `<!-- trek:ui -->` 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';
+92 -7
View File
@@ -4,15 +4,23 @@
* result (no throw) so the CLI can print every problem at once. * 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 { export interface PluginManifest {
id: string; id: string;
name: string; name: string;
version: string; version: string;
apiVersion: number; apiVersion: number;
type: 'integration' | 'page' | 'widget'; type: 'integration' | 'page' | 'widget' | 'trip-page';
permissions: string[]; permissions: string[];
egress: string[]; egress: string[];
nativeModules?: boolean; nativeModules?: boolean;
requiredAddons: string[];
pluginDependencies: PluginDependency[];
} }
export interface ValidationResult { export interface ValidationResult {
ok: boolean; ok: boolean;
@@ -25,14 +33,25 @@ const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
// install loader, so surface it locally too. // install loader, so surface it locally too.
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']); const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; 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 // 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`, // `*.`-wildcard with a multi-label suffix. No `*`, no `*.`, no whole-TLD `*.com`,
// no spaces (mirrors the server manifest validator). // no spaces (mirrors the server manifest validator).
const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i; 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 = [ const KNOWN_PERMISSIONS = [
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user', 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:write:costs',
'hook:photo-provider', 'hook:calendar-source', 'http:outbound', '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 { 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 "*"'); 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}"`); 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; const capabilities = (m.capabilities ?? undefined) as { widget?: { slot?: unknown }; provides?: unknown; emits?: unknown } | undefined;
if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') { const widget = capabilities?.widget;
errors.push(`widget slot must be "sidebar" or "hero", got "${String(widget.slot)}"`); 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 }; if (errors.length) return { ok: false, errors };
return { return {
@@ -88,6 +113,66 @@ export function validateManifest(raw: unknown): ValidationResult {
permissions, permissions,
egress, egress,
nativeModules: false, 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<string, unknown>;
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;
}
+247 -8
View File
@@ -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 * 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 { export interface MockHostOptions {
grants?: string[]; grants?: string[];
config?: Record<string, unknown>; config?: Record<string, unknown>;
/** Fixtures keyed by trip id; `members` gates access like the real host. */ /**
trips?: Record<number, { members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[] }>; * 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<number, unknown>; users?: Record<number, unknown>;
/** Optional canned db.query results, keyed by the exact sql string. */ /** Optional canned db.query results, keyed by the exact sql string. */
queryResults?: Record<string, unknown[]>; queryResults?: Record<string, unknown[]>;
/** 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<string, Record<string, (args: unknown) => unknown>>;
} }
export interface MockHost { export interface MockHost {
@@ -25,6 +45,8 @@ export interface MockHost {
calls: { method: string; args: unknown[] }[]; calls: { method: string; args: unknown[] }[];
logs: { level: string; msg: string }[]; logs: { level: string; msg: string }[];
broadcasts: { kind: 'trip' | 'user'; target: number; event: string; data: unknown }[]; 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 {} class PermissionDenied extends Error {}
@@ -34,6 +56,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
const calls: MockHost['calls'] = []; const calls: MockHost['calls'] = [];
const logs: MockHost['logs'] = []; const logs: MockHost['logs'] = [];
const broadcasts: MockHost['broadcasts'] = []; const broadcasts: MockHost['broadcasts'] = [];
const emitted: MockHost['emitted'] = [];
const need = (perm: string, method: string) => { const need = (perm: string, method: string) => {
calls.push({ method, args: [] }); 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}`); if (!t || !t.members.includes(asUserId)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${tripId}`);
return t; 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<Record<string, unknown>> => (arr ?? []) as Array<Record<string, unknown>>;
// In-memory namespaced metadata store for ctx.meta (per mock plugin).
const metaStore: Record<string, unknown> = {};
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 = { const ctx: PluginContext = {
id: 'mock-plugin', id: 'mock-plugin',
@@ -65,21 +112,199 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
trips: { trips: {
async getById(tripId, asUserId) { async getById(tripId, asUserId) {
need('db:read:trips', 'trips.getById'); 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) { async getPlaces(tripId, asUserId) {
need('db:read:trips', 'trips.getPlaces'); need('db:read:trips', 'trips.getPlaces');
return assertMember(tripId, asUserId).places ?? []; return (assertMember(tripId, asUserId ?? requireActingUser()).places ?? []) as Place[];
}, },
async getReservations(tripId, asUserId) { async getReservations(tripId, asUserId) {
need('db:read:trips', 'trips.getReservations'); 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<string, unknown>;
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<string, unknown> = {};
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: { users: {
async getById(id) { async getById(id) {
need('db:read:users', 'users.getById'); need('db:read:users', 'users.getById');
return opts.users?.[id] ?? null; return (opts.users?.[id] ?? null) as User | null;
}, },
}, },
ws: { ws: {
@@ -97,7 +322,21 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
warn: (msg) => logs.push({ level: 'warn', msg }), warn: (msg) => logs.push({ level: 'warn', msg }),
error: (msg) => logs.push({ level: 'error', 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 };
} }
+335
View File
@@ -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
* <link>/<script src> for an opaque frame). Authors opt in with a single
* `<!-- trek:ui -->` marker; `dev`/`pack` expand it, and `create` seeds it.
*
* The kit carries its own default values, so a component looks right on first paint,
* then the bootstrap overrides the live tokens the host sends (accent scheme, custom
* accent, high-contrast, light/dark) so the plugin tracks the app exactly. The glassy
* `.trek-dash` layer (the --glass, --r and --sh families) is scoped to the dashboard
* in the host, so it can't be read over the bridge — those values are baked here
* (they only change
* with light/dark, keyed off `[data-theme="dark"]`, not with the accent).
*
* Nothing here is a security boundary: it is the plugin's own inlined CSS/JS talking
* over the existing bridge. It grants no new capability — only a native look.
*/
/** Marker an author drops in `client/index.html`; `dev`/`pack` replace it with the kit. */
export const TREK_UI_MARKER = '<!-- trek:ui -->';
/** Token-driven stylesheet. Inline as `<style>${TREK_UI_CSS}</style>`. */
export const TREK_UI_CSS = `/* TREK plugin design kit — token-driven, matches the host in light + dark. */
:root {
color-scheme: light;
/* Live tokens (the host overrides these per theme/accent via the bridge). */
--bg-primary: #ffffff; --bg-secondary: #f8fafc; --bg-tertiary: #f1f5f9;
--bg-card: #ffffff; --bg-input: #ffffff; --bg-hover: rgba(0,0,0,.03); --bg-selected: #e2e8f0;
--text-primary: #111827; --text-secondary: #374151; --text-muted: #6b7280; --text-faint: #9ca3af;
--border-primary: #e5e7eb; --border-secondary: #f3f4f6; --border-faint: rgba(0,0,0,.06);
--accent: #111827; --accent-text: #ffffff; --accent-hover: #1f2937; --accent-subtle: #f1f5f9;
--success: #16a34a; --success-soft: #dcfce7; --danger: #dc2626; --danger-soft: #fef2f2;
--warning: #d97706; --warning-soft: #fffbeb; --info: #2563eb; --info-soft: #eff6ff;
--shadow-card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
--shadow-sm: 0 1px 2px rgba(0,0,0,.05); --shadow-md: 0 4px 12px rgba(0,0,0,.08);
--shadow-lg: 0 12px 32px rgba(0,0,0,.12);
--radius-sm: 8px; --radius-md: 12px; --radius-lg: 16px; --radius-xl: 20px;
--font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
/* Baked glass layer (mirrors the host's .trek-dash; not sent over the bridge). */
--glass-bg: linear-gradient(135deg, oklch(1 0 0 / .72) 0%, oklch(0.99 0.006 75 / .5) 100%);
--glass-border: oklch(0.88 0.008 70 / .7);
--glass-shadow: 0 1px 2px oklch(0.4 0.02 60 / .05), 0 12px 32px -14px oklch(0.3 0.02 60 / .2);
--glass-shadow-hover: 0 2px 6px oklch(0.4 0.02 60 / .07), 0 26px 56px -20px oklch(0.25 0.04 60 / .32);
--glass-highlight: inset 0 1px 0 oklch(1 0 0 / .8);
--glass-blur: blur(22px) saturate(1.7);
--r-sm: 14px; --r-md: 18px; --r-lg: 22px; --r-xl: 28px;
/* The house easings: a punchy card curve and TREK's ease-out-quint. */
--trek-ease: cubic-bezier(.2,.7,.2,1);
--trek-ease-quint: cubic-bezier(.23,1,.32,1);
}
[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #121215; --bg-secondary: #1a1a1e; --bg-tertiary: #1c1c21;
--bg-card: #131316; --bg-input: #1c1c21; --bg-hover: rgba(255,255,255,.06); --bg-selected: rgba(255,255,255,.1);
--text-primary: #f4f4f5; --text-secondary: #d4d4d8; --text-muted: #a1a1aa; --text-faint: #71717a;
--border-primary: #27272a; --border-secondary: #1c1c21; --border-faint: rgba(255,255,255,.07);
--accent: #e4e4e7; --accent-text: #09090b; --accent-hover: #d4d4d8; --accent-subtle: rgba(255,255,255,.08);
--success: #22c55e; --success-soft: rgba(34,197,94,.15); --danger: #ef4444; --danger-soft: rgba(239,68,68,.15);
--warning: #f59e0b; --warning-soft: rgba(245,158,11,.15); --info: #3b82f6; --info-soft: rgba(59,130,246,.15);
--shadow-card: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
--shadow-sm: 0 1px 2px rgba(0,0,0,.3); --shadow-md: 0 4px 12px rgba(0,0,0,.4);
--shadow-lg: 0 12px 32px rgba(0,0,0,.5);
--glass-bg: linear-gradient(135deg, oklch(0.31 0 0 / .58) 0%, oklch(0.25 0 0 / .42) 100%);
--glass-border: oklch(1 0 0 / .1);
--glass-shadow: 0 1px 2px oklch(0 0 0 / .3), 0 12px 32px -14px oklch(0 0 0 / .55);
--glass-shadow-hover: 0 2px 6px oklch(0 0 0 / .4), 0 26px 56px -20px oklch(0 0 0 / .72);
--glass-highlight: inset 0 1px 0 oklch(1 0 0 / .09);
}
/* Base: a light reset + native type. The bootstrap adds \`trek-ui\` to <body>. */
*, *::before, *::after { box-sizing: border-box; }
body.trek-ui {
margin: 0;
font-family: var(--font-system);
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: transparent;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.trek-ui :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* Cards + panels ----------------------------------------------------------- */
.trek-card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
}
.trek-glass {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--r-xl);
box-shadow: var(--glass-shadow), var(--glass-highlight);
-webkit-backdrop-filter: var(--glass-blur);
backdrop-filter: var(--glass-blur);
padding: 24px 26px;
}
/* Add to a card/glass to make it lift on hover, like a native tool tile. */
.trek-interactive {
transition: transform .3s var(--trek-ease), box-shadow .3s, border-color .3s;
cursor: pointer;
}
.trek-glass.trek-interactive:hover {
transform: translateY(-2px);
box-shadow: var(--glass-shadow-hover), var(--glass-highlight);
}
.trek-card.trek-interactive:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.trek-interactive:active { transform: translateY(0); }
/* Buttons ------------------------------------------------------------------ */
.trek-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 16px; border-radius: 12px;
font: inherit; font-size: 14px; font-weight: 500; line-height: 1;
border: 1px solid transparent; cursor: pointer; text-decoration: none; white-space: nowrap;
transition: transform .08s var(--trek-ease-quint), background .15s, box-shadow .15s, border-color .15s, color .15s;
}
.trek-btn:active { transform: scale(.97); }
.trek-btn:disabled { opacity: .5; cursor: not-allowed; }
.trek-btn--primary { background: var(--accent); color: var(--accent-text); box-shadow: var(--shadow-sm); }
.trek-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
.trek-btn--secondary { background: var(--bg-card); color: var(--text-primary); border-color: var(--border-primary); box-shadow: var(--shadow-sm); }
.trek-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); }
.trek-btn--ghost { background: transparent; color: var(--text-secondary); }
.trek-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.trek-btn--danger { background: var(--danger); color: #fff; }
.trek-btn--danger:hover:not(:disabled) { filter: brightness(1.05); }
/* Form controls ------------------------------------------------------------ */
.trek-input, .trek-textarea, .trek-select {
width: 100%; box-sizing: border-box;
padding: 8px 14px; border-radius: 10px;
border: 1px solid var(--border-primary); background: var(--bg-input); color: var(--text-primary);
font: inherit; font-size: 13px; outline: none;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.trek-textarea { resize: vertical; min-height: 72px; }
.trek-input::placeholder, .trek-textarea::placeholder { color: var(--text-faint); }
.trek-input:focus, .trek-textarea:focus, .trek-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 22%, transparent);
}
.trek-label { display: block; font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
/* Chips + badges ----------------------------------------------------------- */
.trek-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
font-size: 12px; font-weight: 600; white-space: nowrap;
color: var(--text-secondary); background: var(--accent-subtle);
}
.trek-chip--accent { color: var(--accent); background: color-mix(in oklch, var(--accent) 12%, transparent); }
.trek-chip--success { color: var(--success); background: var(--success-soft); }
.trek-chip--danger { color: var(--danger); background: var(--danger-soft); }
.trek-chip--warning { color: var(--warning); background: var(--warning-soft); }
.trek-chip--info { color: var(--info); background: var(--info-soft); }
/* Rows + text helpers ------------------------------------------------------ */
.trek-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 12px; cursor: pointer;
transition: background .12s;
}
.trek-row:hover { background: var(--bg-hover); }
.trek-title { font-size: 13px; font-weight: 500; text-transform: uppercase; letter-spacing: .14em; color: var(--text-muted); }
.trek-muted { color: var(--text-muted); }
.trek-faint { color: var(--text-faint); }
/* Layout helpers ----------------------------------------------------------- */
.trek-stack { display: flex; flex-direction: column; gap: 12px; }
.trek-cluster { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
/* Accessibility: mirror the host's own graceful-degrade rules. -------------- */
[data-no-transparency] .trek-glass {
background: var(--bg-card); border-color: var(--border-primary);
box-shadow: var(--shadow-card);
-webkit-backdrop-filter: none; backdrop-filter: none;
}
[data-reduce-motion] .trek-interactive,
[data-reduce-motion] .trek-btn,
[data-reduce-motion] .trek-row { transition: none; }
[data-reduce-motion] .trek-interactive:hover,
[data-reduce-motion] .trek-btn:active { transform: none; }
@media (prefers-reduced-motion: reduce) {
.trek-interactive, .trek-btn, .trek-row, .trek-input, .trek-textarea, .trek-select { transition: none; }
.trek-interactive:hover, .trek-btn:active { transform: none; }
}`;
/**
* The bridge bootstrap. Inline as `<script>${TREK_THEME_JS}</script>` (typically via
* the `<!-- trek:ui -->` 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 `<!-- trek:ui -->` 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 = `<style data-trek-ui>\n${TREK_UI_CSS}\n</style>\n<script data-trek-ui>\n${TREK_THEME_JS}\n</script>`;
return html.split(TREK_UI_MARKER).join(block);
}
+122
View File
@@ -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);
});
});
+137
View File
@@ -87,6 +87,81 @@ describe('createMockHost', () => {
ctx.log.info('hi'); ctx.log.info('hi');
expect(logs).toEqual([{ level: 'info', msg: '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', () => { 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 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('<!-- trek:ui -->'); // 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('<!-- trek:ui -->'); // 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', () => { it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => {
scaffold('my-widget', 'widget', tmp); scaffold('my-widget', 'widget', tmp);
const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8')); 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/); 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);
});
});
+56
View File
@@ -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 <style>/<script> inlining', () => {
expect(TREK_UI_CSS.toLowerCase()).not.toContain('</style');
expect(TREK_UI_CSS.toLowerCase()).not.toContain('</script');
expect(TREK_THEME_JS.toLowerCase()).not.toContain('</script');
expect(TREK_THEME_JS.toLowerCase()).not.toContain('</style');
});
it('injectTrekUi expands the marker into an inline style + script block', () => {
const html = `<!doctype html><html><head></head><body>${TREK_UI_MARKER}</body></html>`;
const out = injectTrekUi(html);
expect(out).not.toContain(TREK_UI_MARKER);
expect(out).toContain('<style data-trek-ui>');
expect(out).toContain('<script data-trek-ui>');
expect(out).toContain('.trek-glass');
expect(out).toContain('window.trek');
});
it('injectTrekUi is a no-op without the marker and expands every occurrence', () => {
const plain = '<html><body><h1>hi</h1></body></html>';
expect(injectTrekUi(plain)).toBe(plain);
const twice = `${TREK_UI_MARKER}<hr>${TREK_UI_MARKER}`;
const out = injectTrekUi(twice);
expect(out.match(/<style data-trek-ui>/g)?.length).toBe(2);
});
});
+2
View File
@@ -43,6 +43,8 @@ DEMO_MODE=false # Demo mode - resets data hourly
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less. # OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000) # OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
# 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 get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here.
# Initial admin account — ONLY applied on the first boot, when the database has no # Initial admin account — ONLY applied on the first boot, when the database has no
# users yet. Adding these later has no effect (the server logs a reminder if you do); # users yet. Adding these later has no effect (the server logs a reminder if you do);
# to change an existing password sign in and use Settings, or reset the admin account. # to change an existing password sign in and use Settings, or reset the admin account.
+64
View File
@@ -3419,6 +3419,70 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);'); db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);');
db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);'); db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);');
}, },
// Migration 161: plugin-owned metadata on core entities (#1429). A namespaced
// key/value store so a plugin can attach data to a trip/place/day WITHOUT
// forking the core schema. One row per (plugin, entity, key); a plugin only
// ever sees its own rows. entity_type is polymorphic (no cross-table FK), so
// rows are purged by plugin_id on uninstall; entity deletes leave harmless
// orphans that the plugin's own reads never surface.
() => {
db.exec(`CREATE TABLE IF NOT EXISTS plugin_entity_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (plugin_id, entity_type, entity_id, key)
);`);
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id);');
},
// Freeze the FX rate on settle-up transfers too (#1445). budget_settlements
// stored only a bare `amount` in whatever display currency the payer was
// viewing, so a later live-rate drift re-opened an already-settled position
// with a few-cent residual. Capture the display `currency` and the rate frozen
// at settle time (units of that currency per 1 trip currency), mirroring the
// budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on
// live rates until re-edited.
() => {
try {
db.exec('ALTER TABLE budget_settlements ADD COLUMN currency TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
try {
db.exec('ALTER TABLE budget_settlements ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// #1446: guests are per-trip people, but their display name lived in the globally
// UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to
// "Jake 2". Add a non-unique display_name; new guests store the human name here and
// get a uuid-based username that is never shown (the member views COALESCE to it).
() => {
try {
db.exec('ALTER TABLE users ADD COLUMN display_name TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare
// `requiredAddons` (addon ids that must be enabled to activate) and
// `pluginDependencies` ({id, version-range} of other plugins that must be
// installed + satisfied). Stored as one JSON blob and populated by the
// discovery upsert on every install path. Legacy rows default to '{}' (no deps).
() => {
try {
db.exec("ALTER TABLE plugins ADD COLUMN dependencies TEXT NOT NULL DEFAULT '{}'");
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+6 -2
View File
@@ -5,7 +5,7 @@ import { isDemoUser } from '../../services/authService';
import { import {
createBudgetItem, updateBudgetItem, deleteBudgetItem, createBudgetItem, updateBudgetItem, deleteBudgetItem,
updateMembers as updateBudgetMembers, updateMembers as updateBudgetMembers,
toggleMemberPaid, getBudgetItem, toggleMemberPaid, getBudgetItem, freezeForeignRate,
calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement, calculateSettlement, listSettlements, createSettlement, updateSettlement, deleteSettlement,
} from '../../services/budgetService'; } from '../../services/budgetService';
import { getRates } from '../../services/exchangeRateService'; import { getRates } from '../../services/exchangeRateService';
@@ -68,7 +68,11 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied(); if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const members = resolveMemberIds(tripId, member_ids); const members = resolveMemberIds(tripId, member_ids);
const item = createBudgetItem(tripId, { category, name, total_price, currency, member_ids: members, payers, expense_date, note }); const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, note };
// Freeze the live FX rate at entry time so a settled position isn't re-opened
// when live rates drift (#1445) — same as the REST create path.
await freezeForeignRate(tripId, itemData);
const item = createBudgetItem(tripId, itemData);
safeBroadcast(tripId, 'budget:created', { item }); safeBroadcast(tripId, 'budget:created', { item });
return ok({ item }); return ok({ item });
} }
+12 -2
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { PluginRuntimeService } from '../plugins/plugin-runtime.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard'; import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
@@ -30,7 +31,10 @@ function ok<T>(result: T): Exclude<T, { error: string }> {
@Controller('api/admin') @Controller('api/admin')
@UseGuards(JwtAuthGuard, AdminGuard) @UseGuards(JwtAuthGuard, AdminGuard)
export class AdminController { export class AdminController {
constructor(private readonly admin: AdminService) {} constructor(
private readonly admin: AdminService,
private readonly pluginRuntime: PluginRuntimeService,
) {}
// ── Users ── // ── Users ──
@Get('users') @Get('users')
@@ -271,13 +275,19 @@ export class AdminController {
listAddons() { return { addons: this.admin.listAddons() }; } listAddons() { return { addons: this.admin.listAddons() }; }
@Put('addons/:id') @Put('addons/:id')
updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) { async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: unknown, @Req() req: Request) {
const result = ok(this.admin.updateAddon(id, body)); const result = ok(this.admin.updateAddon(id, body));
writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails }); writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
// Sessions only need re-creating when the registered MCP surface can // Sessions only need re-creating when the registered MCP surface can
// actually change — an enabled-flip of an MCP-relevant addon. Config-only // actually change — an enabled-flip of an MCP-relevant addon. Config-only
// saves and photo-provider toggles used to kill every session (#1414). // saves and photo-provider toggles used to kill every session (#1414).
if (result.mcpAffected) this.admin.invalidateMcpSessions(); if (result.mcpAffected) this.admin.invalidateMcpSessions();
// Disabling an addon cascades to plugins that require it: stop them (and any
// plugin that transitively depends on them) so a plugin never runs against a
// disabled addon (#plugins dependencies).
if (result.addon && result.addon.enabled === false && (body as { enabled?: boolean })?.enabled === false) {
await this.pluginRuntime.deactivateForDisabledAddon(id);
}
return { addon: result.addon }; return { addon: result.addon };
} }
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { PluginsModule } from '../plugins/plugins.module';
@Module({ @Module({
imports: [PluginsModule],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService], providers: [AdminService],
}) })
@@ -4,7 +4,7 @@ import { checkPermission } from '../../services/permissions';
import { verifyTripAccess } from '../../services/tripAccess'; import { verifyTripAccess } from '../../services/tripAccess';
import { createReservation } from '../../services/reservationService'; import { createReservation } from '../../services/reservationService';
import { createPlace } from '../../services/placeService'; import { createPlace } from '../../services/placeService';
import { createBudgetItem } from '../../services/budgetService'; import { createBudgetItem, freezeForeignRate } from '../../services/budgetService';
import { isAddonEnabled } from '../../services/adminService'; import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons'; import { ADDON_IDS } from '../../addons';
import { searchNominatim } from '../../services/mapsService'; import { searchNominatim } from '../../services/mapsService';
@@ -235,13 +235,17 @@ export class BookingImportService {
const price = meta && meta.price != null ? Number(meta.price) : NaN; const price = meta && meta.price != null ? Number(meta.price) : NaN;
if (Number.isFinite(price) && price > 0) { if (Number.isFinite(price) && price > 0) {
try { try {
const budgetItem = createBudgetItem(tripId, { const budgetData = {
category: typeToCostCategory(item.type), category: typeToCostCategory(item.type),
name: item.title, name: item.title,
total_price: price, total_price: price,
currency: meta && typeof meta.priceCurrency === 'string' ? meta.priceCurrency : null, currency: meta && typeof meta.priceCurrency === 'string' ? meta.priceCurrency : null,
reservation_id: reservation.id, reservation_id: reservation.id,
}); };
// Freeze the live FX rate for a foreign-currency booking price so a
// settled position isn't re-opened when live rates drift (#1445).
await freezeForeignRate(tripId, budgetData);
const budgetItem = createBudgetItem(tripId, budgetData);
broadcast(tripId, 'budget:created', { item: budgetItem }, socketId); broadcast(tripId, 'budget:created', { item: budgetItem }, socketId);
} catch (err) { } catch (err) {
console.error( console.error(
+8 -7
View File
@@ -74,10 +74,10 @@ export class BudgetController {
} }
@Post('settlements') @Post('settlements')
createSettlement( async createSettlement(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('tripId') tripId: string, @Param('tripId') tripId: string,
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number }, @Body() body: { from_user_id?: number; to_user_id?: number; amount?: number; currency?: string | null },
@Headers('x-socket-id') socketId?: string, @Headers('x-socket-id') socketId?: string,
) { ) {
const trip = this.requireTrip(tripId, user); const trip = this.requireTrip(tripId, user);
@@ -85,9 +85,9 @@ export class BudgetController {
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) { if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400); throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
} }
const settlement = this.budget.createSettlement( const settlement = await this.budget.createSettlement(
tripId, tripId,
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount }, { from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount, currency: body.currency },
user.id, user.id,
); );
this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId); this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId);
@@ -95,11 +95,11 @@ export class BudgetController {
} }
@Put('settlements/:settlementId') @Put('settlements/:settlementId')
updateSettlement( async updateSettlement(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('tripId') tripId: string, @Param('tripId') tripId: string,
@Param('settlementId') settlementId: string, @Param('settlementId') settlementId: string,
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number }, @Body() body: { from_user_id?: number; to_user_id?: number; amount?: number; currency?: string | null },
@Headers('x-socket-id') socketId?: string, @Headers('x-socket-id') socketId?: string,
) { ) {
const trip = this.requireTrip(tripId, user); const trip = this.requireTrip(tripId, user);
@@ -107,10 +107,11 @@ export class BudgetController {
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) { if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400); throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
} }
const settlement = this.budget.updateSettlement(settlementId, tripId, { const settlement = await this.budget.updateSettlement(settlementId, tripId, {
from_user_id: body.from_user_id, from_user_id: body.from_user_id,
to_user_id: body.to_user_id, to_user_id: body.to_user_id,
amount: body.amount, amount: body.amount,
currency: body.currency,
}); });
if (!settlement) { if (!settlement) {
throw new HttpException({ error: 'Settlement not found' }, 404); throw new HttpException({ error: 'Settlement not found' }, 404);
+12 -30
View File
@@ -41,39 +41,13 @@ export class BudgetService {
return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
} }
// Freeze the live FX rate at entry time into budget_items.exchange_rate so a settled
// position isn't re-opened when live rates drift later (#1335). Only for a foreign
// currency with no explicit rate; degrades to live rates if the fetch fails. On update
// it (re)freezes only when the currency changes, so an unrelated edit never moves money.
private async freezeForeignRate(
tripId: string,
data: { currency?: string | null; exchange_rate?: number },
existingItemId?: string,
): Promise<void> {
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
const cur = (data.currency || '').toUpperCase();
if (!cur) return; // currency not being set in this request
if (existingItemId != null) {
const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?')
.get(existingItemId) as { currency?: string } | undefined;
if (existing && (existing.currency || '').toUpperCase() === cur) return; // currency unchanged
}
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
.get(tripId) as { currency?: string } | undefined;
const tripCur = (trip?.currency || 'EUR').toUpperCase();
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
const rates = await getRates(tripCur);
const r = rates?.[cur];
if (r && r > 0) data.exchange_rate = r;
}
async create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) { async create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
await this.freezeForeignRate(tripId, data); await svc.freezeForeignRate(tripId, data);
return svc.createBudgetItem(tripId, data); return svc.createBudgetItem(tripId, data);
} }
async update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) { async update(id: string, tripId: string, data: Parameters<typeof svc.updateBudgetItem>[2]) {
await this.freezeForeignRate(tripId, data, id); await svc.freezeForeignRate(tripId, data, id);
return svc.updateBudgetItem(id, tripId, data); return svc.updateBudgetItem(id, tripId, data);
} }
@@ -97,11 +71,19 @@ export class BudgetService {
return svc.listSettlements(tripId); return svc.listSettlements(tripId);
} }
createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }, userId: number) { async createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }, userId: number) {
// Freeze the FX rate for the display currency the amount was entered in so the
// transfer keeps cancelling its expense when live rates drift (#1445).
await svc.freezeForeignRate(tripId, data);
return svc.createSettlement(tripId, data, userId); return svc.createSettlement(tripId, data, userId);
} }
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) { async updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null }) {
// Pass the settlement's stored currency so an edit that doesn't change it keeps
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
// settled position on an unrelated edit.
const existing = svc.listSettlements(tripId).find((s) => s.id === Number(id));
await svc.freezeForeignRate(tripId, data, undefined, existing?.currency ?? null);
return svc.updateSettlement(id, tripId, data); return svc.updateSettlement(id, tripId, data);
} }
@@ -42,9 +42,17 @@ export class FilesDownloadController {
} }
// Serve Apple Wallet passes inline with the canonical MIME type so Safari // Serve Apple Wallet passes inline with the canonical MIME type so Safari
// (iOS/macOS) hands them to Wallet instead of downloading as a blob. // (iOS/macOS) hands them to Wallet instead of downloading as a blob. A
if (path.extname(resolved).toLowerCase() === '.pkpass') { // `.pkpasses` bundle (a ZIP of multiple passes) is a distinct type with its
res.setHeader('Content-Type', 'application/vnd.apple.pkpass'); // own plural MIME type — without it Wallet won't offer to add the passes.
const walletMime =
path.extname(resolved).toLowerCase() === '.pkpass'
? 'application/vnd.apple.pkpass'
: path.extname(resolved).toLowerCase() === '.pkpasses'
? 'application/vnd.apple.pkpasses'
: null;
if (walletMime) {
res.setHeader('Content-Type', walletMime);
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`); res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
} }
+150
View File
@@ -0,0 +1,150 @@
import semver from 'semver';
import type { PluginDependency } from './install/manifest';
/**
* Pure dependency-reasoning helpers for the plugin runtime (#plugins). Kept
* side-effect free (no DB, no process) so the activation gate, the addon-disable
* cascade, and the registry resolver can all share — and unit-test — the same
* logic. The DB layer hands these functions plain rows; they never query.
*/
export interface PluginDependencies {
requiredAddons: string[];
pluginDependencies: PluginDependency[];
}
/** The subset of a `plugins` row these helpers reason over. */
export interface PluginDepRow {
id: string;
version: string | null;
enabled: number;
/** The `dependencies` JSON blob column (may be null/legacy '{}'). */
dependencies: string | null;
}
/** Thrown when the dependency graph contains a cycle (A → B → A). */
export class DependencyCycleError extends Error {
constructor(readonly cyclePath: string[]) {
super(`plugin dependency cycle: ${cyclePath.join(' -> ')}`);
this.name = 'DependencyCycleError';
}
}
const EMPTY: PluginDependencies = { requiredAddons: [], pluginDependencies: [] };
/** Parse the stored `dependencies` JSON blob, tolerating legacy/garbage values. */
export function parseDependencies(raw: string | null | undefined): PluginDependencies {
if (!raw) return { requiredAddons: [], pluginDependencies: [] };
try {
const o = JSON.parse(raw) as Partial<PluginDependencies>;
return {
requiredAddons: Array.isArray(o.requiredAddons) ? o.requiredAddons.filter((a): a is string => typeof a === 'string') : [],
pluginDependencies: Array.isArray(o.pluginDependencies)
? o.pluginDependencies.filter(
(d): d is PluginDependency =>
!!d && typeof d === 'object' && typeof (d as PluginDependency).id === 'string' && typeof (d as PluginDependency).version === 'string',
)
: [],
};
} catch {
return { ...EMPTY };
}
}
/** Required addon ids that are currently NOT enabled — these block activation. */
export function disabledRequiredAddons(deps: PluginDependencies, isAddonEnabled: (id: string) => boolean): string[] {
return deps.requiredAddons.filter((a) => !isAddonEnabled(a));
}
export interface VersionMismatch {
id: string;
/** The declared semver range. */
wanted: string;
/** The installed version that fails the range. */
installed: string;
}
export interface DependencyState {
/** Declared deps not installed at all. */
missing: PluginDependency[];
/** Installed but the version is outside the declared range. */
versionMismatch: VersionMismatch[];
/** Installed + satisfied but currently disabled (auto-enable candidates). */
satisfiedDisabled: string[];
/** Installed + satisfied + already enabled. */
satisfiedActive: string[];
}
/** Classify each declared plugin dependency against what is installed. */
export function resolveDependencyState(deps: PluginDependencies, installed: Map<string, PluginDepRow>): DependencyState {
const state: DependencyState = { missing: [], versionMismatch: [], satisfiedDisabled: [], satisfiedActive: [] };
for (const dep of deps.pluginDependencies) {
const row = installed.get(dep.id);
if (!row) {
state.missing.push(dep);
continue;
}
const installedVersion = row.version ?? '0.0.0';
if (!semver.satisfies(installedVersion, dep.version, { includePrerelease: true })) {
state.versionMismatch.push({ id: dep.id, wanted: dep.version, installed: installedVersion });
continue;
}
if (row.enabled) state.satisfiedActive.push(dep.id);
else state.satisfiedDisabled.push(dep.id);
}
return state;
}
/** Plugins whose `pluginDependencies` reference `pluginId` (direct dependents). */
export function findDependents(pluginId: string, rows: PluginDepRow[]): string[] {
return rows.filter((r) => parseDependencies(r.dependencies).pluginDependencies.some((d) => d.id === pluginId)).map((r) => r.id);
}
/**
* All plugins that (transitively) depend on `pluginId`. Used by the addon-disable
* cascade: disabling the addon disables the plugins that require it AND everything
* downstream of those. Order is leaf-most last is NOT guaranteed; callers only
* need the set for deactivation.
*/
export function findDependentsTransitive(pluginId: string, rows: PluginDepRow[]): string[] {
const out: string[] = [];
const seen = new Set<string>([pluginId]);
const queue = [pluginId];
while (queue.length) {
const cur = queue.shift() as string;
for (const dep of findDependents(cur, rows)) {
if (seen.has(dep)) continue;
seen.add(dep);
out.push(dep);
queue.push(dep);
}
}
return out;
}
/**
* Deps-first activation order for the given root ids over the installed graph.
* Edges to plugins that are NOT installed are ignored here (the activation gate
* reports those as missing separately). Throws {@link DependencyCycleError} on a
* back-edge. Passing multiple roots (boot) yields one global order with each
* plugin appearing once, dependencies always before their dependents.
*/
export function enableOrder(ids: string[], installed: Map<string, PluginDepRow>): string[] {
const order: string[] = [];
const done = new Set<string>();
const stack: string[] = [];
const visit = (cur: string): void => {
if (done.has(cur)) return;
const cycleAt = stack.indexOf(cur);
if (cycleAt !== -1) throw new DependencyCycleError([...stack.slice(cycleAt), cur]);
const row = installed.get(cur);
if (!row) return; // not installed — the gate handles it upstream
stack.push(cur);
for (const dep of parseDependencies(row.dependencies).pluginDependencies) visit(dep.id);
stack.pop();
done.add(cur);
order.push(cur);
};
for (const id of ids) visit(id);
return order;
}
+220 -2
View File
@@ -1,9 +1,44 @@
import { db, canAccessTrip } from '../../../db/database'; import { db, canAccessTrip } from '../../../db/database';
import { broadcast, broadcastToUser } from '../../../websocket'; import { broadcast, broadcastToUser } from '../../../websocket';
import { listBudgetItems } from '../../../services/budgetService';
import { listItems as listPackingItemsSvc } from '../../../services/packingService';
import { listFiles } from '../../../services/fileService';
import { checkPermission } from '../../../services/permissions';
import { listTrips, updateTrip, NotFoundError, ValidationError } from '../../../services/tripService';
import { createPlace, updatePlace, deletePlace } from '../../../services/placeService';
import { createDay, getDay, updateDay, deleteDay } from '../../../services/dayService';
import { createAssignment, deleteAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../services/assignmentService';
import { isAddonEnabled } from '../../../services/adminService';
import { ADDON_IDS } from '../../../addons';
import { BudgetService } from '../../budget/budget.service';
import { PluginDataDb } from './plugin-data.service'; import { PluginDataDb } from './plugin-data.service';
import { PluginRpcHost } from './rpc-host'; import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
import { appendAudit } from './plugin-audit'; import { appendAudit } from './plugin-audit';
/**
* The trip-access + role gate used by every planner write, mirroring the app's
* per-domain `canEdit` (canAccessTrip + checkPermission for the entity's *_edit
* action). Returns false — never throws — so the caller maps it to a clean
* RESOURCE_FORBIDDEN.
*/
function canEditTripAs(action: string, tripId: number, userId: number): boolean {
const trip = canAccessTrip(tripId, userId) as { user_id: number } | undefined;
if (!trip) return false;
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
if (!u) return false;
return checkPermission(action, u.role ?? 'user', trip.user_id, userId, trip.user_id !== userId);
}
// Reused for costs.create so a plugin write frozen-FX and members/payers logic
// matches a normal web-app budget write exactly (it has no injected deps).
const budgetSvc = new BudgetService();
// Quotas for plugin entity metadata (db:meta) — a cheap disk-DoS guard on the
// shared trek.db volume. Generous for real use, small enough to bound abuse.
const META_VALUE_MAX = 64 * 1024; // serialized JSON bytes per value
const META_KEY_MAX = 256; // key string length (the key is attacker-controlled too)
const META_KEYS_MAX = 100; // keys per (plugin, entity)
/** /**
* Wires a plugin's capability host to the REAL privileged modules (#plugins, * Wires a plugin's capability host to the REAL privileged modules (#plugins,
* M1). This is the ONLY plugin file that imports db/websocket — it runs in the * M1). This is the ONLY plugin file that imports db/websocket — it runs in the
@@ -27,11 +62,20 @@ export function closePluginDataDb(id: string): void {
dataDbs.delete(id); dataDbs.delete(id);
} }
export function createRealRpcHost(id: string, granted: ReadonlySet<string>): PluginRpcHost { /** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */
export interface PluginCallRouter {
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
emitPluginEvent(sourceId: string, event: string, payload: unknown): void;
}
export function createRealRpcHost(id: string, granted: ReadonlySet<string>, router: PluginCallRouter): PluginRpcHost {
return new PluginRpcHost(id, granted, { return new PluginRpcHost(id, granted, {
data: getPluginDataDb(id), data: getPluginDataDb(id),
db, db,
canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId), canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId),
// The router binds this host's plugin id as the caller/source.
callPlugin: (targetId, fn, args, actingUserId) => router.callPlugin(id, targetId, fn, args, actingUserId),
emitPluginEvent: (event, payload) => router.emitPluginEvent(id, event, payload),
// Two users "share a trip" when both are owner-or-member of the same trip. // Two users "share a trip" when both are owner-or-member of the same trip.
canSeeUser: (actingUserId, targetUserId) => canSeeUser: (actingUserId, targetUserId) =>
!!db !!db
@@ -47,5 +91,179 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>): Plu
broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload), broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload),
broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }), broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }),
audit: (entry) => appendAudit(db, entry), audit: (entry) => appendAudit(db, entry),
// --- Costs (budget items) ---
budgetAddonEnabled: () => isAddonEnabled(ADDON_IDS.BUDGET),
// Same gate as a REST/MCP budget mutation: the acting user must have trip
// access AND the 'budget_edit' permission for their global role.
canEditCosts: (tripId, userId) => {
const trip = canAccessTrip(tripId, userId) as { user_id: number } | undefined;
if (!trip) return false;
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
if (!u) return false;
return checkPermission('budget_edit', u.role ?? 'user', trip.user_id, userId, trip.user_id !== userId);
},
// --- Read scopes (packing/files). Membership is checked by the host (tripRead);
// these just delegate to the same services the REST paths use. ---
listPackingItems: (tripId, userId) => listPackingItemsSvc(tripId, userId),
listTripFiles: (tripId) => listFiles(tripId, false),
listCostsForTrip: (tripId) => listBudgetItems(tripId),
// Cross-trip: every accessible trip's budget items (membership predicate is
// baked into listTrips). Reuses the hydrated list so members/payers come too.
listCostsForUser: (userId) => {
const trips = listTrips(userId, null) as Array<{ id: number }>;
return trips.flatMap((t) => listBudgetItems(t.id));
},
// Reuses BudgetService.create (frozen FX + members/payers), then broadcasts
// the same 'budget:created' event the controller emits so the web app updates
// live. No X-Socket-Id — a plugin has no originating socket.
createCost: async (tripId, input) => {
const item = await budgetSvc.create(String(tripId), input);
broadcast(tripId, 'budget:created', { item });
return item;
},
// Reuses BudgetService.update (re-frozen FX on a currency change), then
// broadcasts the same 'budget:updated' event the REST controller emits. A
// missing item is a clean RESOURCE_FORBIDDEN (parity with updatePlace).
updateCost: async (tripId, itemId, input) => {
const item = await budgetSvc.update(String(itemId), String(tripId), input);
if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
broadcast(tripId, 'budget:updated', { item });
return item;
},
// Reuses BudgetService.remove, then broadcasts 'budget:deleted' with the
// numeric id — same payload the REST controller sends.
deleteCost: (tripId, itemId) => {
const deleted = budgetSvc.remove(String(itemId), String(tripId));
if (!deleted) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
broadcast(tripId, 'budget:deleted', { itemId });
return { deleted: true };
},
// --- Places (place_edit). Delegate to the same placeService the REST/MCP paths
// use, then broadcast the same events so open web sessions update live. ---
canEditPlaces: (tripId, userId) => canEditTripAs('place_edit', tripId, userId),
createPlace: (tripId, input) => {
const place = createPlace(String(tripId), input as Parameters<typeof createPlace>[1]);
broadcast(tripId, 'place:created', { place });
return place;
},
updatePlace: (tripId, placeId, input) => {
const place = updatePlace(String(tripId), String(placeId), input as Parameters<typeof updatePlace>[2]);
if (place === null) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
broadcast(tripId, 'place:updated', { place });
return place;
},
deletePlace: (tripId, placeId) => {
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
broadcast(tripId, 'place:deleted', { placeId });
return { deleted: true };
},
// --- Days (day_edit). getDay scopes the row to the trip before any write. ---
canEditDays: (tripId, userId) => canEditTripAs('day_edit', tripId, userId),
createDay: (tripId, input) => {
const i = input as { date?: string; notes?: string };
const day = createDay(tripId, i.date, i.notes);
broadcast(tripId, 'day:created', { day });
return day;
},
updateDay: (tripId, dayId, input) => {
const current = getDay(dayId, tripId);
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
const day = updateDay(dayId, current, input as { notes?: string; title?: string | null });
broadcast(tripId, 'day:updated', { day });
return day;
},
deleteDay: (tripId, dayId) => {
const current = getDay(dayId, tripId);
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
deleteDay(dayId);
broadcast(tripId, 'day:deleted', { dayId });
return { deleted: true };
},
// --- Itinerary (day_edit). Both the day AND the place must belong to the trip,
// so a plugin can't cross-link another trip's rows (assignmentService doesn't
// self-check this — the controllers do, so we reproduce it here). ---
assignPlaceToDay: (tripId, dayId, placeId, notes) => {
if (!dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
if (!placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
const assignment = createAssignment(dayId, placeId, notes);
broadcast(tripId, 'assignment:created', { assignment });
return assignment;
},
unassignPlace: (tripId, assignmentId) => {
if (!getAssignmentForTrip(assignmentId, tripId)) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
deleteAssignment(assignmentId);
broadcast(tripId, 'assignment:deleted', { assignmentId });
return { deleted: true };
},
// --- Trip (trip_edit). Only the schema-writable fields reach updateTrip; its
// NotFound/Validation errors are mapped to clean RPC codes. ---
canEditTrip: (tripId, userId) => canEditTripAs('trip_edit', tripId, userId),
updateTrip: (tripId, userId, input) => {
// The REST controller gates two fields behind their OWN admin-configurable
// permissions, separate from trip_edit — reproduce that here so a plugin (or
// its member user) can't archive or re-cover a trip it may only edit.
if ('is_archived' in input && !canEditTripAs('trip_archive', tripId, userId)) {
throw new ForbiddenResource(`no permission to archive trip ${tripId}`);
}
if ('cover_image' in input && !canEditTripAs('trip_cover_upload', tripId, userId)) {
throw new ForbiddenResource(`no permission to change the cover of trip ${tripId}`);
}
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
try {
const result = updateTrip(tripId, userId, input as Parameters<typeof updateTrip>[2], u?.role ?? 'user');
broadcast(tripId, 'trip:updated', { trip: result.updatedTrip });
return result.updatedTrip;
} catch (e) {
if (e instanceof ValidationError) throw new BadParams(e.message);
if (e instanceof NotFoundError) throw new ForbiddenResource(e.message);
throw e;
}
},
// --- Plugin metadata (db:meta). A per-plugin namespaced key/value store keyed
// to a core entity; the plugin only ever sees rows tagged with its own id. ---
metaEntityTrip: (entityType, entityId) => {
if (entityType === 'trip') {
return (db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id;
}
const table = entityType === 'place' ? 'places' : 'days';
return (db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id;
},
metaGet: (entityType, entityId, key) => {
const row = db.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(id, entityType, entityId, key) as { value: string } | undefined;
if (!row) return null;
try { return JSON.parse(row.value); } catch { return null; }
},
metaSet: (entityType, entityId, key, value) => {
if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`);
const json = JSON.stringify(value ?? null);
if (json.length > META_VALUE_MAX) throw new BadParams(`metadata value too large (>${META_VALUE_MAX} bytes)`);
const exists = db.prepare('SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(id, entityType, entityId, key);
if (!exists) {
const { n } = db.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
.get(id, entityType, entityId) as { n: number };
if (n >= META_KEYS_MAX) throw new BadParams(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`);
}
db.prepare(`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(plugin_id, entity_type, entity_id, key)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
.run(id, entityType, entityId, key, json);
return { key, value: value ?? null };
},
metaList: (entityType, entityId) => {
const list = db.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key')
.all(id, entityType, entityId) as Array<{ key: string; value: string }>;
const out: Record<string, unknown> = {};
for (const r of list) { try { out[r.key] = JSON.parse(r.value); } catch { out[r.key] = null; } }
return out;
},
metaDelete: (entityType, entityId, key) => {
const res = db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.run(id, entityType, entityId, key);
return { deleted: res.changes > 0 };
},
}); });
} }
+21 -1
View File
@@ -30,15 +30,35 @@ export interface AuditEntry {
/** The methods worth auditing: core-data reads + broadcasts, not own-db noise. */ /** The methods worth auditing: core-data reads + broadcasts, not own-db noise. */
export function auditResource(method: string, params: Record<string, unknown>): string | null { export function auditResource(method: string, params: Record<string, unknown>): string | null {
if (method.startsWith('trips.')) return `trip:${params.tripId ?? '?'}`; if (method.startsWith('trips.')) return `trip:${params.tripId ?? '?'}`;
if (method === 'costs.listMine') return 'costs:all';
if (method.startsWith('costs.')) return `trip:${params.tripId ?? '?'}`;
if (method.startsWith('places.') || method.startsWith('days.') || method.startsWith('itinerary.')) return `trip:${params.tripId ?? '?'}`;
if (method.startsWith('packing.') || method.startsWith('files.')) return `trip:${params.tripId ?? '?'}`;
if (method.startsWith('meta.')) return `${params.entityType ?? '?'}:${params.entityId ?? '?'}`;
if (method === 'users.getById') return `user:${params.id ?? '?'}`; if (method === 'users.getById') return `user:${params.id ?? '?'}`;
if (method === 'ws.broadcastToTrip') return `trip:${params.tripId ?? '?'}`; if (method === 'ws.broadcastToTrip') return `trip:${params.tripId ?? '?'}`;
if (method === 'ws.broadcastToUser') return `user:${params.userId ?? '?'}`; if (method === 'ws.broadcastToUser') return `user:${params.userId ?? '?'}`;
if (method === 'plugins.call') return `plugin:${params.targetId ?? '?'}#${params.fn ?? '?'}`;
if (method === 'events.emit') return `event:${params.event ?? '?'}`;
return null; return null;
} }
/** True for calls we record (core data + ws); a plugin's own-db calls are skipped. */ /** True for calls we record (core data + ws); a plugin's own-db calls are skipped. */
export function isAuditable(method: string): boolean { export function isAuditable(method: string): boolean {
return method.startsWith('trips.') || method.startsWith('users.') || method.startsWith('ws.'); return (
method.startsWith('trips.') ||
method.startsWith('costs.') ||
method.startsWith('places.') ||
method.startsWith('days.') ||
method.startsWith('itinerary.') ||
method.startsWith('packing.') ||
method.startsWith('files.') ||
method.startsWith('meta.') ||
method.startsWith('users.') ||
method.startsWith('ws.') ||
method.startsWith('plugins.') ||
method.startsWith('events.')
);
} }
/** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */ /** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */
+314 -3
View File
@@ -1,3 +1,10 @@
import {
budgetCreateItemRequestSchema, type BudgetCreateItemRequest,
budgetUpdateItemRequestSchema, type BudgetUpdateItemRequest,
placeCreateRequestSchema, placeUpdateRequestSchema,
dayCreateRequestSchema, dayUpdateRequestSchema,
tripUpdateRequestSchema,
} from '@trek/shared';
import { import {
KNOWN_METHODS, KNOWN_METHODS,
type KnownMethod, type KnownMethod,
@@ -44,6 +51,53 @@ export interface HostDeps {
broadcastToUser(userId: number, payload: Record<string, unknown>): void; broadcastToUser(userId: number, payload: Record<string, unknown>): void;
/** Optional sink for the capability audit log (host-side, hash-chained). */ /** Optional sink for the capability audit log (host-side, hash-chained). */
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void; audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void;
/** Call an export on another plugin (this host's plugin is the caller). Authorizes
* the dependency edge + the target's `provides` allowlist, forwards the acting user. */
callPlugin(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
/** Publish an event from this host's plugin to its subscribed dependents. */
emitPluginEvent(event: string, payload: unknown): void;
/** True when the Costs (budget) addon is enabled — gates all costs.* methods. */
budgetAddonEnabled(): boolean;
/** True if the acting user may create costs on the trip (the 'budget_edit' permission). */
canEditCosts(tripId: number, userId: number): boolean;
/** A trip's packing items visible to `userId` (#858 private-item filter), for `packing.list`. */
listPackingItems(tripId: number, userId: number): unknown[];
/** A trip's files (trash excluded), for `files.list`. */
listTripFiles(tripId: number): unknown[];
/** All budget items of one trip, hydrated with members/payers. */
listCostsForTrip(tripId: number): unknown[];
/** All budget items across every trip the acting user can access. */
listCostsForUser(userId: number): unknown[];
/** Create a budget item on a trip (and broadcast); returns the created item. */
createCost(tripId: number, input: BudgetCreateItemRequest): unknown;
/** Update a budget item on a trip (and broadcast); returns the updated item. */
updateCost(tripId: number, itemId: number, input: BudgetUpdateItemRequest): unknown;
/** Delete a budget item from a trip (and broadcast); returns { deleted: true }. */
deleteCost(tripId: number, itemId: number): unknown;
// --- Places (the 'place_edit' permission) ---
canEditPlaces(tripId: number, userId: number): boolean;
createPlace(tripId: number, input: Record<string, unknown>): unknown;
updatePlace(tripId: number, placeId: number, input: Record<string, unknown>): unknown;
deletePlace(tripId: number, placeId: number): unknown;
// --- Days + itinerary (the 'day_edit' permission) ---
canEditDays(tripId: number, userId: number): boolean;
createDay(tripId: number, input: Record<string, unknown>): unknown;
updateDay(tripId: number, dayId: number, input: Record<string, unknown>): unknown;
deleteDay(tripId: number, dayId: number): unknown;
/** Assign a place to a day (both trip-scoped by the wiring); returns the assignment. */
assignPlaceToDay(tripId: number, dayId: number, placeId: number, notes: string | null): unknown;
/** Remove a day-assignment (trip-scoped by the wiring). */
unassignPlace(tripId: number, assignmentId: number): unknown;
// --- Trip (the 'trip_edit' permission) ---
canEditTrip(tripId: number, userId: number): boolean;
updateTrip(tripId: number, userId: number, input: Record<string, unknown>): unknown;
// --- Plugin metadata on core entities (db:meta) ---
/** The trip a trip/place/day belongs to (for the membership gate), or undefined. */
metaEntityTrip(entityType: string, entityId: number): number | undefined;
metaGet(entityType: string, entityId: number, key: string): unknown;
metaSet(entityType: string, entityId: number, key: string, value: unknown): unknown;
metaList(entityType: string, entityId: number): unknown;
metaDelete(entityType: string, entityId: number, key: string): unknown;
} }
type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown; type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown;
@@ -57,7 +111,11 @@ const str = (v: unknown, name: string): string => {
if (typeof v !== 'string') throw new BadParams(`${name} must be a string`); if (typeof v !== 'string') throw new BadParams(`${name} must be a string`);
return v; return v;
}; };
class BadParams extends Error {} export class BadParams extends Error {}
// Mirrors the STRING_LIMITS the places REST controller enforces (the @trek/shared
// schema doesn't), so the plugin write path rejects the same oversized fields.
const PLACE_STR_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
export class PluginRpcHost { export class PluginRpcHost {
private methods = new Map<string, Handler>(); private methods = new Map<string, Handler>();
@@ -86,6 +144,183 @@ export class PluginRpcHost {
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))), this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))),
); );
} }
if (has('db:read:packing')) {
// Delegate to the packing service, scoped to the acting user so its #858 private-
// item visibility filter applies (a plugin must not see other members' private items).
this.methods.set('packing.list', (p, uid) =>
this.tripRead(p, uid, (userId) => deps.listPackingItems(num(p.tripId, 'tripId'), userId)),
);
}
if (has('db:read:files')) {
// Trip files, trash excluded — same view the files tab shows.
this.methods.set('files.list', (p, uid) =>
this.tripRead(p, uid, () => deps.listTripFiles(num(p.tripId, 'tripId'))),
);
}
if (has('db:read:costs')) {
// "Costs" = budget items (trip-scoped). Same membership gate as trip reads;
// additionally requires the Costs addon to be enabled (parity with the app,
// where a disabled addon means there is nothing to read).
this.methods.set('costs.getByTrip', (p, uid) =>
this.tripRead(p, uid, () => {
this.requireBudgetAddon();
return deps.listCostsForTrip(num(p.tripId, 'tripId'));
}),
);
// Cross-trip aggregate: every cost the acting user can access. The acting
// user is host-bound; a job/onLoad (no user) is refused, same as tripRead.
this.methods.set('costs.listMine', (p, uid) => {
if (uid === undefined) throw new ForbiddenResource('cost reads require an authenticated user context');
this.requireBudgetAddon();
return deps.listCostsForUser(uid);
});
}
if (has('db:write:costs')) {
// The first plugin path that MUTATES core data. Gate it exactly like a
// normal web-app/MCP budget write: addon enabled + trip access + the
// 'budget_edit' permission for the host-bound acting user.
this.methods.set('costs.create', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
if (uid === undefined) throw new ForbiddenResource('cost writes require an authenticated user context');
this.requireBudgetAddon();
const parsed = budgetCreateItemRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid cost: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!this.deps.canEditCosts(tripId, uid)) throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
return deps.createCost(tripId, parsed.data);
});
// Same gate as costs.create — addon + trip access + the acting user's
// 'budget_edit' permission — plus the item id. updateCost re-freezes the FX
// rate through BudgetService.update exactly like the create path.
this.methods.set('costs.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const itemId = num(p.itemId, 'itemId');
if (uid === undefined) throw new ForbiddenResource('cost writes require an authenticated user context');
this.requireBudgetAddon();
const parsed = budgetUpdateItemRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid cost: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!this.deps.canEditCosts(tripId, uid)) throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
return deps.updateCost(tripId, itemId, parsed.data);
});
// Deleting a cost is a budget write too: gated by db:write:costs and, per the
// app, the acting user's 'budget_edit' permission on the trip.
this.methods.set('costs.delete', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const itemId = num(p.itemId, 'itemId');
if (uid === undefined) throw new ForbiddenResource('cost writes require an authenticated user context');
this.requireBudgetAddon();
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!this.deps.canEditCosts(tripId, uid)) throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
return deps.deleteCost(tripId, itemId);
});
}
// --- Core planner writes (#1429). Each mirrors costs.create: validate the
// input against the SAME @trek/shared schema the web app uses, then gate on
// trip access + the entity's edit permission for the HOST-bound acting user
// (a job/onLoad has no user, so its writes are refused). The delegating deps
// reuse the real services + broadcast the same events, so the app stays live. ---
if (has('db:write:places')) {
this.methods.set('places.create', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const actor = this.requireActor(uid, 'place');
const parsed = placeCreateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
return deps.createPlace(tripId, parsed.data as Record<string, unknown>);
});
this.methods.set('places.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const placeId = num(p.placeId, 'placeId');
const actor = this.requireActor(uid, 'place');
const parsed = placeUpdateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid place: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
return deps.updatePlace(tripId, placeId, parsed.data as Record<string, unknown>);
});
this.methods.set('places.delete', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const placeId = num(p.placeId, 'placeId');
const actor = this.requireActor(uid, 'place');
this.requireTripEdit(tripId, actor, deps.canEditPlaces);
return deps.deletePlace(tripId, placeId);
});
}
if (has('db:write:days')) {
this.methods.set('days.create', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const actor = this.requireActor(uid, 'day');
const parsed = dayCreateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.createDay(tripId, parsed.data as Record<string, unknown>);
});
this.methods.set('days.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const dayId = num(p.dayId, 'dayId');
const actor = this.requireActor(uid, 'day');
const parsed = dayUpdateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid day: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.updateDay(tripId, dayId, parsed.data as Record<string, unknown>);
});
this.methods.set('days.delete', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const dayId = num(p.dayId, 'dayId');
const actor = this.requireActor(uid, 'day');
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.deleteDay(tripId, dayId);
});
}
if (has('db:write:itinerary')) {
// Assigning/removing a place on a day is a DAY edit in the app (day_edit), so
// gate it with canEditDays; the wiring also checks the day AND place belong to
// the trip so a plugin can't cross-link another trip's rows.
this.methods.set('itinerary.assign', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const dayId = num(p.dayId, 'dayId');
const placeId = num(p.placeId, 'placeId');
const actor = this.requireActor(uid, 'itinerary');
const notes = p.notes === undefined || p.notes === null ? null : str(p.notes, 'notes');
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.assignPlaceToDay(tripId, dayId, placeId, notes);
});
this.methods.set('itinerary.unassign', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const assignmentId = num(p.assignmentId, 'assignmentId');
const actor = this.requireActor(uid, 'itinerary');
this.requireTripEdit(tripId, actor, deps.canEditDays);
return deps.unassignPlace(tripId, assignmentId);
});
}
if (has('db:write:trips')) {
this.methods.set('trips.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const actor = this.requireActor(uid, 'trip');
const parsed = tripUpdateRequestSchema.safeParse(p.input);
if (!parsed.success) throw new BadParams(`invalid trip: ${parsed.error.issues[0]?.message ?? 'bad input'}`);
this.requireTripEdit(tripId, actor, deps.canEditTrip);
return deps.updateTrip(tripId, actor, parsed.data as Record<string, unknown>);
});
}
if (has('db:meta')) {
// A plugin's OWN namespaced key/value store attached to a core entity. Not
// core data — but the entity must belong to a trip the acting user can
// ACCESS, so a plugin can't stash/read metadata against another tenant's rows.
this.methods.set('meta.get', (p, uid) => { const e = this.metaEntity(p, uid, false); return deps.metaGet(e.entityType, e.entityId, str(p.key, 'key')); });
this.methods.set('meta.set', (p, uid) => { const e = this.metaEntity(p, uid, true); return deps.metaSet(e.entityType, e.entityId, str(p.key, 'key'), p.value); });
this.methods.set('meta.list', (p, uid) => { const e = this.metaEntity(p, uid, false); return deps.metaList(e.entityType, e.entityId); });
this.methods.set('meta.delete', (p, uid) => { const e = this.metaEntity(p, uid, true); return deps.metaDelete(e.entityType, e.entityId, str(p.key, 'key')); });
}
if (has('db:read:users')) { if (has('db:read:users')) {
// Scope to people the acting user can actually see (self or a trip they // Scope to people the acting user can actually see (self or a trip they
@@ -122,6 +357,18 @@ export class PluginRpcHost {
return { ok: true }; return { ok: true };
}); });
} }
// Inter-plugin capabilities (#plugins deps). Registered UNCONDITIONALLY — there
// is no permission for these; the router authorizes each call against the
// declared dependency edge + the target's `provides`/`emits` allowlist. The
// acting user is forwarded so the target's export runs as the caller's user.
this.methods.set('plugins.call', (p, uid) =>
deps.callPlugin(str(p.targetId, 'targetId'), str(p.fn, 'fn'), p.args, uid),
);
this.methods.set('events.emit', (p) => {
deps.emitPluginEvent(str(p.event, 'event'), p.payload);
return { ok: true };
});
} }
/** /**
@@ -131,7 +378,7 @@ export class PluginRpcHost {
* could set to any id to read another user's trips. If no acting user is bound * could set to any id to read another user's trips. If no acting user is bound
* (a job / onLoad, or a forged call), the read is forbidden. * (a job / onLoad, or a forged call), the read is forbidden.
*/ */
private tripRead(p: Record<string, unknown>, actingUserId: number | undefined, read: () => unknown): unknown { private tripRead(p: Record<string, unknown>, actingUserId: number | undefined, read: (userId: number) => unknown): unknown {
const tripId = num(p.tripId, 'tripId'); const tripId = num(p.tripId, 'tripId');
if (actingUserId === undefined) { if (actingUserId === undefined) {
throw new ForbiddenResource('trip reads require an authenticated user context'); throw new ForbiddenResource('trip reads require an authenticated user context');
@@ -139,7 +386,71 @@ export class PluginRpcHost {
if (!this.deps.canAccessTrip(tripId, actingUserId)) { if (!this.deps.canAccessTrip(tripId, actingUserId)) {
throw new ForbiddenResource(`no access to trip ${tripId}`); throw new ForbiddenResource(`no access to trip ${tripId}`);
} }
return read(); // The read runs only for a bound, membership-checked user — hand it through so
// per-user visibility filters (e.g. packing's #858 private items) can apply.
return read(actingUserId);
}
/** Refuse costs.* calls when the Costs (budget) addon is disabled. */
private requireBudgetAddon(): void {
if (!this.deps.budgetAddonEnabled()) {
throw new ForbiddenResource('the costs addon is disabled');
}
}
/**
* Every write needs a HOST-bound acting user. A job / onLoad (no user) or a call
* with a forged/unknown invocation id resolves to undefined and is refused — a
* plugin can never write "as" an arbitrary user.
*/
private requireActor(uid: number | undefined, noun: string): number {
if (uid === undefined) throw new ForbiddenResource(`${noun} writes require an authenticated user context`);
return uid;
}
/**
* The @trek/shared write schemas don't carry the string-length caps the REST
* controllers add, so mirror those caps here — otherwise a plugin could write a
* field the web app would reject with 400 (e.g. a 100k-char place name).
*/
private capStrings(input: Record<string, unknown>, limits: Record<string, number>): void {
for (const [field, max] of Object.entries(limits)) {
const v = input[field];
if (typeof v === 'string' && v.length > max) throw new BadParams(`${field} must be ${max} characters or fewer`);
}
}
/** A write is allowed only if the acting user can access AND edit the trip. */
private requireTripEdit(tripId: number, uid: number, canEdit: (t: number, u: number) => boolean): void {
if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit trip ${tripId}`);
}
/**
* Validate a metadata target and gate it: the entity type must be one we support,
* and the trip it belongs to must be accessible to the host-bound acting user.
*/
private metaEntity(p: Record<string, unknown>, uid: number | undefined, write: boolean): { entityType: string; entityId: number } {
const entityType = str(p.entityType, 'entityType');
if (entityType !== 'trip' && entityType !== 'place' && entityType !== 'day') {
throw new BadParams(`invalid entityType "${entityType}" (trip|place|day)`);
}
const entityId = num(p.entityId, 'entityId');
if (uid === undefined) throw new ForbiddenResource('metadata requires an authenticated user context');
const tripId = this.deps.metaEntityTrip(entityType, entityId);
if (tripId === undefined || !this.deps.canAccessTrip(tripId, uid)) {
throw new ForbiddenResource(`no access to ${entityType} ${entityId}`);
}
// Reads need trip access; WRITES additionally need the entity's edit permission
// — so a read-only member can't overwrite/delete metadata an editor created
// (matches how core writes are gated).
if (write) {
const canEdit = entityType === 'trip' ? this.deps.canEditTrip
: entityType === 'place' ? this.deps.canEditPlaces
: this.deps.canEditDays;
if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit ${entityType} ${entityId}`);
}
return { entityType, entityId };
} }
async dispatch(req: RpcRequest, actingUserId?: number): Promise<RpcResponse | RpcError> { async dispatch(req: RpcRequest, actingUserId?: number): Promise<RpcResponse | RpcError> {
+6 -5
View File
@@ -41,19 +41,20 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
} }
function upsert(db: BetterSqlite3.Database, m: PluginManifest): void { function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
const dependencies = JSON.stringify({ requiredAddons: m.requiredAddons, pluginDependencies: m.pluginDependencies });
const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined; const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined;
if (existing) { if (existing) {
db.prepare( db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?, `UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, permissions = ?, capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), m.id); ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.id);
} else { } else {
db.prepare( db.prepare(
// granted_permissions '' (empty, not '[]') marks "never consented" so the // granted_permissions '' (empty, not '[]') marks "never consented" so the
// first activation is distinguishable from a plugin consented to zero perms. // first activation is distinguishable from a plugin consented to zero perms.
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, granted_permissions, status) `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities)); ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies);
} }
// Refresh the settings-field descriptors from the manifest. // Refresh the settings-field descriptors from the manifest.
+81 -5
View File
@@ -1,3 +1,4 @@
import semver from 'semver';
import { isKnownPermission } from '../protocol/envelope'; import { isKnownPermission } from '../protocol/envelope';
/** /**
@@ -23,12 +24,23 @@ export interface ManifestSettingField {
export interface WidgetCapability { export interface WidgetCapability {
title?: string; title?: string;
defaultSize?: string; defaultSize?: string;
/** Where the widget mounts: dashboard sidebar (default) or as a hero-bar overlay. */ /** Where the widget mounts: dashboard sidebar (default), hero-bar overlay, or the
slot?: 'sidebar' | 'hero'; * trip planner's place-detail panel (scoped to the selected place). */
slot?: 'sidebar' | 'hero' | 'place-detail';
} }
export interface PluginCapabilities { export interface PluginCapabilities {
widget?: WidgetCapability; widget?: WidgetCapability;
/** Function names this plugin exposes to its dependents via ctx.plugins.call. */
provides?: string[];
/** Event names this plugin publishes to its dependents via ctx.events.emit. */
emits?: string[];
}
/** A declared dependency on another plugin, pinned by a semver range. */
export interface PluginDependency {
id: string;
version: string;
} }
export interface PluginManifest { export interface PluginManifest {
@@ -40,7 +52,7 @@ export interface PluginManifest {
description?: string; description?: string;
homepage?: string; homepage?: string;
icon?: string; icon?: string;
type: 'integration' | 'page' | 'widget'; type: 'integration' | 'page' | 'widget' | 'trip-page';
trek?: string; trek?: string;
minTrekVersion?: string; minTrekVersion?: string;
nativeModules: boolean; nativeModules: boolean;
@@ -48,9 +60,17 @@ export interface PluginManifest {
egress: string[]; egress: string[];
settings: ManifestSettingField[]; settings: ManifestSettingField[];
capabilities: PluginCapabilities; capabilities: PluginCapabilities;
/** Addon ids that must be enabled for this plugin to activate (format-only here). */
requiredAddons: string[];
/** Other plugins that must be installed + version-satisfied to activate. */
pluginDependencies: PluginDependency[];
} }
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/; const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
// Addon ids are lowercase slugs that may contain underscores (e.g. `llm_parsing`).
// Validated format-only: existence is checked at activation, so a plugin declaring
// an unknown addon still installs but can never enable (matches "allow install").
const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
// An outbound host: an exact hostname (single-label like a `redis` sibling // An outbound host: an exact hostname (single-label like a `redis` sibling
// service, or a dotted FQDN) OR a `*.`-prefixed wildcard that MUST have a real // service, or a dotted FQDN) OR a `*.`-prefixed wildcard that MUST have a real
// multi-label suffix. Rejects `*`, `*.`, whole-TLD `*.com`, schemes, and any // multi-label suffix. Rejects `*`, `*.`, whole-TLD `*.com`, schemes, and any
@@ -61,7 +81,9 @@ const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
// (id "registry" would collide with GET registry/:id vs :id/errors routing). // (id "registry" would collide with GET registry/:id vs :id/errors routing).
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']); const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const TYPES = new Set(['integration', 'page', 'widget']); // 'trip-page' mounts the plugin's sandboxed UI as a tab inside every trip planner
// (tripId-scoped), with no dashboard presence — unlike 'page' (dashboard nav).
const TYPES = new Set(['integration', 'page', 'widget', 'trip-page']);
export class ManifestError extends Error {} export class ManifestError extends Error {}
@@ -122,9 +144,47 @@ export function parseManifest(raw: unknown): PluginManifest {
egress, egress,
settings: parseSettings(m.settings), settings: parseSettings(m.settings),
capabilities: parseCapabilities(m.capabilities), capabilities: parseCapabilities(m.capabilities),
requiredAddons: parseRequiredAddons(m.requiredAddons),
pluginDependencies: parsePluginDependencies(m.pluginDependencies, id),
}; };
} }
/** Validate `requiredAddons`: a de-duplicated list of well-formed addon-id slugs. */
function parseRequiredAddons(raw: unknown): string[] {
const out: string[] = [];
for (const v of arr(raw)) {
if (typeof v !== 'string' || !ADDON_ID_RE.test(v)) throw new ManifestError(`invalid requiredAddons entry "${String(v)}"`);
if (!out.includes(v)) out.push(v);
}
return out;
}
/**
* Validate `pluginDependencies`: each `{ id, version }` must reference a valid,
* non-self, non-reserved plugin id and a parseable semver range. Duplicate ids
* are rejected so a dependency can't be declared with two conflicting ranges.
*/
function parsePluginDependencies(raw: unknown, selfId: string): PluginDependency[] {
const out: PluginDependency[] = [];
for (const v of arr(raw)) {
if (!v || typeof v !== 'object') throw new ManifestError('each pluginDependencies entry must be an object');
const d = v as Record<string, unknown>;
const id = str(d.id, 'pluginDependencies.id');
if (!ID_RE.test(id)) throw new ManifestError(`invalid pluginDependencies id "${id}"`);
if (RESERVED_IDS.has(id)) throw new ManifestError(`reserved pluginDependencies id "${id}"`);
if (id === selfId) throw new ManifestError(`plugin "${selfId}" cannot depend on itself`);
if (out.some((e) => e.id === id)) throw new ManifestError(`duplicate pluginDependencies id "${id}"`);
const version = str(d.version, 'pluginDependencies.version');
if (semver.validRange(version) === null) throw new ManifestError(`invalid pluginDependencies version range "${version}" for "${id}"`);
out.push({ id, version });
}
return out;
}
// Export function / event names exposed to other plugins. Kept to a safe
// identifier shape (dots allowed for event names like `rate.updated`).
const CAPABILITY_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/;
function parseCapabilities(raw: unknown): PluginCapabilities { function parseCapabilities(raw: unknown): PluginCapabilities {
if (!raw || typeof raw !== 'object') return {}; if (!raw || typeof raw !== 'object') return {};
const c = raw as Record<string, unknown>; const c = raw as Record<string, unknown>;
@@ -132,13 +192,29 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
if (c.widget && typeof c.widget === 'object') { if (c.widget && typeof c.widget === 'object') {
const w = c.widget as Record<string, unknown>; const w = c.widget as Record<string, unknown>;
const slot = optStr(w.slot); const slot = optStr(w.slot);
if (slot && slot !== 'sidebar' && slot !== 'hero') throw new ManifestError(`invalid widget slot "${slot}"`); if (slot && slot !== 'sidebar' && slot !== 'hero' && slot !== 'place-detail') throw new ManifestError(`invalid widget slot "${slot}"`);
out.widget = { out.widget = {
title: optStr(w.title), title: optStr(w.title),
defaultSize: optStr(w.defaultSize), defaultSize: optStr(w.defaultSize),
slot: (slot as WidgetCapability['slot']) ?? 'sidebar', slot: (slot as WidgetCapability['slot']) ?? 'sidebar',
}; };
} }
const provides = parseCapabilityNames(c.provides, 'provides');
if (provides.length) out.provides = provides;
const emits = parseCapabilityNames(c.emits, 'emits');
if (emits.length) out.emits = emits;
return out;
}
/** Validate a `provides`/`emits` array: de-duplicated, well-formed names. */
function parseCapabilityNames(raw: unknown, field: string): string[] {
if (raw === undefined) return [];
if (!Array.isArray(raw)) throw new ManifestError(`capabilities.${field} must be an array of names`);
const out: string[] = [];
for (const v of raw) {
if (typeof v !== 'string' || !CAPABILITY_NAME_RE.test(v)) throw new ManifestError(`invalid capabilities.${field} entry "${String(v)}"`);
if (!out.includes(v)) out.push(v);
}
return out; return out;
} }
@@ -0,0 +1,52 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { db, canAccessTrip } from '../../db/database';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
/**
* GET /api/place-details/:placeId — extra info for a place, contributed by plugins
* that implement the `placeDetailProvider` hook (#1429). Additive and fail-safe:
* the place must belong to a trip the caller can access, each provider is called
* host→plugin with a short timeout, and a provider that errors or times out is
* simply skipped — it never delays or breaks the place panel.
*/
interface ProviderResult {
pluginId: string;
items: unknown;
}
@Controller('api/place-details')
@UseGuards(JwtAuthGuard)
export class PlaceDetailsController {
constructor(private readonly runtime: PluginRuntimeService) {}
@Get(':placeId')
async get(
@Param('placeId') placeIdRaw: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ providers: ProviderResult[] }> {
if (!pluginsEnabled()) return { providers: [] };
const placeId = Number(placeIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(placeId) || userId == null) return { providers: [] };
// The place must belong to a trip the caller can access — same gate as a read.
const row = db.prepare('SELECT trip_id FROM places WHERE id = ?').get(placeId) as { trip_id: number } | undefined;
if (!row || !canAccessTrip(row.trip_id, userId)) return { providers: [] };
const ids = this.runtime.providersOf('placeDetailProvider');
const results = await Promise.all(
ids.map(async (id): Promise<ProviderResult | null> => {
try {
const items = await this.runtime.invokeHook(id, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000);
return { pluginId: id, items };
} catch {
return null; // a slow / failing provider is skipped, never fatal
}
}),
);
return { providers: results.filter((r): r is ProviderResult => r !== null) };
}
}
+267 -23
View File
@@ -1,15 +1,22 @@
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common'; import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
import semver from 'semver';
import { db } from '../../db/database'; import { db } from '../../db/database';
import { pluginsEnabled } from './kill-switch'; import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
import { decrypt_api_key } from '../../services/apiKeyCrypto'; import { decrypt_api_key } from '../../services/apiKeyCrypto';
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
import fs from 'node:fs'; import fs from 'node:fs';
import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host'; import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host';
import { ForbiddenResource } from './host/rpc-host';
import { removePluginData } from './host/plugin-data.service'; import { removePluginData } from './host/plugin-data.service';
import { isKnownPermission } from './protocol/envelope'; import { isKnownPermission } from './protocol/envelope';
import { discoverPlugins } from './install/discovery'; import { discoverPlugins } from './install/discovery';
import { pluginCodeDir } from './paths'; import { pluginCodeDir } from './paths';
import { PluginRegistryService } from './registry/registry.service'; import { PluginRegistryService } from './registry/registry.service';
import { isAddonEnabled } from '../../services/adminService';
import type { PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
const HTTP_OUTBOUND = 'http:outbound:'; const HTTP_OUTBOUND = 'http:outbound:';
@@ -20,6 +27,24 @@ export class PluginConsentRequired extends Error {
} }
} }
export type PluginDependencyCode = 'ADDON_DISABLED' | 'DEPENDENCY_MISSING';
/**
* Thrown when a plugin can't activate because a required addon is disabled or a
* declared plugin dependency is missing / version-mismatched. The controller maps
* it to a 409 carrying `code` + `detail` so the admin UI can offer the right fix.
*/
export class PluginDependencyError extends Error {
constructor(
message: string,
readonly code: PluginDependencyCode,
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[] } = {},
) {
super(message);
this.name = 'PluginDependencyError';
}
}
/** /**
* Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2). * Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2).
* Bridges the DB registry (`plugins` rows) to the process supervisor: activate * Bridges the DB registry (`plugins` rows) to the process supervisor: activate
@@ -28,17 +53,12 @@ export class PluginConsentRequired extends Error {
* `active` plugins on startup when the runtime is enabled. * `active` plugins on startup when the runtime is enabled.
*/ */
interface PluginRow {
id: string;
status: string;
permissions: string;
granted_permissions: string;
config: string;
}
@Injectable() @Injectable()
export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
private readonly supervisor = new PluginSupervisor(createRealRpcHost, { // The rpc-host factory is bound to `this` as the inter-plugin router, so a
// plugin's ctx.plugins.call / ctx.events.emit resolve through callPlugin/
// emitPluginEvent below (which own the dependency-edge authorization).
private readonly supervisor = new PluginSupervisor((id, granted) => createRealRpcHost(id, granted, this), {
onStatus: (id, status, error) => { onStatus: (id, status, error) => {
db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
status, status,
@@ -59,30 +79,67 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
onModuleInit(): void { onModuleInit(): void {
if (!pluginsEnabled()) return; if (!pluginsEnabled()) return;
// Forward core trip events to plugins that subscribed (events:subscribe). The
// sink is name-only + fire-and-forget, so it can never block a core broadcast.
setPluginEventSink((tripId, event) => this.supervisor.deliverEvent(tripId, event));
// Discover plugins placed on the volume (registers new ones inactive), then // Discover plugins placed on the volume (registers new ones inactive), then
// boot the ones an admin had already activated. // boot the ones an admin had already activated — in dependency order so a
// plugin's dependencies come up before it does. The whole block is defensive:
// boot must NEVER block app init, even in a context without plugin tables
// (e.g. a slimmed-down test app that only imports AdminModule).
try { try {
discoverPlugins(db); discoverPlugins(db);
const installed = this.installedDepRows();
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
let order: string[];
try {
order = enableOrder(enabledIds, installed);
} catch {
// A cycle among enabled plugins — fall back to arbitrary order; each plugin's
// own gate still refuses to spawn, so nothing boots into a broken state.
order = enabledIds;
}
for (const id of order) {
this.activate(id).catch((e) => {
// A plugin whose required addon is off (or a dependency is missing) at boot
// must not stay marked enabled — reconcile the row so the UI reflects reality.
if (e instanceof PluginDependencyError || e instanceof DependencyCycleError) {
this.deactivate(id).catch(() => {});
}
/* other failures: status is persisted as error by the supervisor hook */
});
}
} catch { } catch {
/* discovery must never block boot */ /* discovery/boot must never block app init */
}
// Boot everything the admin left ENABLED, regardless of the last runtime
// status — a crash or a bad deploy set status='error' but must not silently
// keep the plugin down forever.
const enabled = db.prepare('SELECT id FROM plugins WHERE enabled = 1').all() as Array<{ id: string }>;
for (const { id } of enabled) {
this.activate(id).catch(() => {
/* status is persisted as error by the supervisor hook */
});
} }
} }
/**
* Disable every plugin that can no longer run now that `addonId` was turned off:
* plugins that require the addon, plus everything that (transitively) depends on
* them. Called from the admin addon-toggle handler. No-op for an addon no plugin
* requires. Returns the ids actually deactivated.
*/
async deactivateForDisabledAddon(addonId: string): Promise<string[]> {
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
const directlyAffected = rows
.filter((r) => r.enabled && parseDependencies(r.dependencies).requiredAddons.includes(addonId))
.map((r) => r.id);
const affected = new Set<string>(directlyAffected);
for (const id of directlyAffected) for (const dep of findDependentsTransitive(id, rows)) affected.add(dep);
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
const toDisable = [...affected].filter((id) => enabledById.get(id));
for (const id of toDisable) await this.deactivate(id).catch(() => {});
return toDisable;
}
/** Re-scan the plugins volume on demand (admin action). */ /** Re-scan the plugins volume on demand (admin action). */
rescan(): { discovered: string[]; skipped: string[] } { rescan(): { discovered: string[]; skipped: string[] } {
return discoverPlugins(db); return discoverPlugins(db);
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
setPluginEventSink(null);
await this.supervisor.shutdownAll(); await this.supervisor.shutdownAll();
} }
@@ -97,8 +154,40 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
* update consent dialog), and otherwise throws PluginConsentRequired. * update consent dialog), and otherwise throws PluginConsentRequired.
*/ */
async activate(id: string, consentWiden = false): Promise<void> { async activate(id: string, consentWiden = false): Promise<void> {
const row = db.prepare('SELECT id, status, permissions, granted_permissions, config FROM plugins WHERE id = ?').get(id) as const installed = this.installedDepRows();
| PluginRow // Deps-first order over the installed graph (throws DependencyCycleError on a
// cycle). Missing deps aren't in `installed` so they don't appear here — the
// per-node gate reports those separately.
const order = enableOrder([id], installed);
const rootInstalled = installed.has(id);
// Read-only pre-flight over the whole chain BEFORE mutating anything, so a
// blocked dependency never leaves the chain half-activated. Only the target
// may consent-widen; dependencies are auto-enabled at their existing grant.
const toCheck = rootInstalled ? order : [id];
for (const nodeId of toCheck) this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false);
// Enable dependencies first (skip ones already enabled), then the target.
for (const nodeId of order) {
if (nodeId !== id && installed.get(nodeId)?.enabled) continue;
await this.spawnActivated(nodeId);
}
}
/** All plugin rows projected to what the dependency helpers reason over. */
private installedDepRows(): Map<string, PluginDepRow> {
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
return new Map(rows.map((r) => [r.id, r]));
}
/**
* Read-only activation gate for one plugin — throws (without mutating) if it may
* not activate. Checks run most- to least-severe: permission re-consent →
* required addon disabled → missing/mismatched plugin dependency.
*/
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
const row = db.prepare('SELECT permissions, granted_permissions, dependencies FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null }
| undefined; | undefined;
if (!row) throw new Error(`plugin ${id} not found`); if (!row) throw new Error(`plugin ${id} not found`);
@@ -114,10 +203,34 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND)); const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND));
throw new PluginConsentRequired(`plugin ${id} requests new permissions; explicit re-consent is required`, newPermissions, newEgress); throw new PluginConsentRequired(`plugin ${id} requests new permissions; explicit re-consent is required`, newPermissions, newEgress);
} }
const deps = parseDependencies(row.dependencies);
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
if (disabledAddons.length) {
throw new PluginDependencyError(`plugin ${id} requires disabled addon(s): ${disabledAddons.join(', ')}`, 'ADDON_DISABLED', {
addons: disabledAddons,
});
}
const state = resolveDependencyState(deps, installed);
if (state.missing.length || state.versionMismatch.length) {
throw new PluginDependencyError(`plugin ${id} has unmet plugin dependencies`, 'DEPENDENCY_MISSING', {
missing: state.missing,
versionMismatch: state.versionMismatch,
});
}
}
/** Mark a (pre-validated) plugin enabled and spawn its child. */
private async spawnActivated(id: string): Promise<void> {
const row = db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as
| { permissions: string; config: string }
| undefined;
if (!row) throw new Error(`plugin ${id} not found`);
const declared = parseArray(row.permissions).filter(isKnownPermission);
// Mark it enabled (admin intent) so it reboots after restarts/crashes. // Mark it enabled (admin intent) so it reboots after restarts/crashes.
db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id); db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
const config = decryptConfig(parseObject(row.config)); const config = decryptConfig(parseObject(row.config));
const egress = declared.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length)); const egress = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
await this.supervisor.activate(id, new Set(declared), config, egress); await this.supervisor.activate(id, new Set(declared), config, egress);
} }
@@ -127,6 +240,26 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id); db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
} }
/**
* Admin-initiated deactivation: turn `id` off AND every plugin that (transitively)
* depends on it — a dependent can't run without its dependency (its ctx.plugins.call
* would fail), so it must not be left enabled. Dependents are stopped before the
* dependency. Returns every id actually deactivated (dependents first, then `id`).
*
* This is separate from the low-level `deactivate()` so the internal stop-then-
* restart of update()/sideload() never disables a plugin's dependents.
*/
async deactivateWithDependents(id: string): Promise<string[]> {
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
// findDependentsTransitive returns nearest-first; reverse so the deepest dependent
// (the furthest caller) stops before the plugin it depends on.
const dependents = findDependentsTransitive(id, rows).filter((d) => enabledById.get(d)).reverse();
const order = [...dependents, id];
for (const pid of order) await this.deactivate(pid).catch(() => {});
return order;
}
/** /**
* Update a plugin to the registry's latest version with a re-consent gate: the * Update a plugin to the registry's latest version with a re-consent gate: the
* new version's declared permissions are diffed against what the admin already * new version's declared permissions are diffed against what the admin already
@@ -166,6 +299,33 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
return { version: res.version, activated: false, newPermissions, newEgress }; return { version: res.version, activated: false, newPermissions, newEgress };
} }
/**
* Sideload a plugin from an uploaded archive (admin "Upload plugin"). Extracts +
* validates into staging first, stops any running child of the same id (its code
* dir is about to be replaced, and on Windows the child holds file locks), then
* commits it as an INACTIVE sideloaded plugin. Never auto-activates — the admin
* re-activates (and re-consents to permissions) explicitly.
*/
async sideload(bytes: Buffer): Promise<{ id: string; version: string; replaced: boolean }> {
if (!this.registry) throw new Error('registry service unavailable');
const staged = this.registry.stageUpload(bytes);
try {
const replaced = !!db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id);
// Force any replaced plugin INACTIVE before the swap: stop a running child
// (it holds file locks and would keep executing stale code) AND clear the
// active flag, so replaced code can never keep running — or even show active
// — without a fresh activation + permission consent. deactivate() no-ops on
// a plugin that isn't running.
if (replaced) await this.deactivate(staged.id);
this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
return { id: staged.id, version: staged.version, replaced };
} catch (e) {
// A failure before commitUpload leaves staging behind — clean it up.
try { fs.rmSync(staged.stagingDir, { recursive: true, force: true }); } catch {}
throw e;
}
}
/** Stop the plugin, remove its code, and optionally delete all its data. */ /** Stop the plugin, remove its code, and optionally delete all its data. */
async uninstall(id: string, deleteData: boolean): Promise<void> { async uninstall(id: string, deleteData: boolean): Promise<void> {
await this.supervisor.disable(id); await this.supervisor.disable(id);
@@ -178,6 +338,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
removePluginData(id); removePluginData(id);
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id); db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`); db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`);
db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id);
} }
} }
@@ -202,6 +363,89 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
invoke(id: string, method: string, params: Record<string, unknown>, actingUserId?: number): Promise<unknown> { invoke(id: string, method: string, params: Record<string, unknown>, actingUserId?: number): Promise<unknown> {
return this.supervisor.invoke(id, method, params, { actingUserId }); return this.supervisor.invoke(id, method, params, { actingUserId });
} }
/** Ids of active plugins implementing a provider hook (e.g. 'placeDetailProvider'). */
providersOf(hook: string): string[] {
return this.supervisor.providersOf(hook);
}
/**
* Ask ONE plugin's provider hook for data (host→plugin). A tighter default
* timeout than a route call so a slow provider can't delay the core response;
* the acting user is host-bound so any trip read the hook makes is membership-checked.
*/
invokeHook(id: string, hook: string, fn: string, args: unknown[], actingUserId?: number, timeoutMs = 5000): Promise<unknown> {
// Defense-in-depth: only a plugin that both implements the hook AND holds the
// hook:* grant (providersOf enforces both) may be invoked, even if a caller
// passes an id directly rather than one returned by providersOf.
if (!this.supervisor.providersOf(hook).includes(id)) {
return Promise.reject(new Error(`plugin ${id} is not a granted provider of ${hook}`));
}
return this.supervisor.invoke(id, 'invoke.hook', { hook, fn, args }, { actingUserId, timeoutMs });
}
// ── Inter-plugin router (implements PluginCallRouter; #plugins deps) ──────────
/**
* Route `caller`'s ctx.plugins.call to `target`'s export. Authorization is the
* dependency edge — the caller must declare `target` as a version-satisfied
* `pluginDependency` — plus the target must expose `fn` (declared in its manifest
* `capabilities.provides` AND implemented, as reported at load). The acting user
* is forwarded, so the target's export runs membership-checked as the caller's user.
*/
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown> {
if (!this.supervisor.isActive(targetId)) {
return Promise.reject(new ForbiddenResource(`plugin ${targetId} is not active`));
}
if (!this.dependsOnSatisfied(callerId, targetId)) {
return Promise.reject(new ForbiddenResource(`plugin ${callerId} does not declare ${targetId} as a satisfied dependency`));
}
if (!this.capabilityList(targetId, 'provides').includes(fn) || !this.supervisor.exportsOf(targetId).includes(fn)) {
return Promise.reject(new ForbiddenResource(`plugin ${targetId} does not export "${fn}"`));
}
return this.supervisor.invoke(targetId, 'invoke.export', { fn, args }, { actingUserId, timeoutMs: 5000 });
}
/**
* Fan out an event emitted by `source` to every active plugin that (a) subscribed
* to `(source, event)` and (b) declares `source` as a satisfied dependency. The
* source must declare `event` in its manifest `capabilities.emits`. Fire-and-forget.
*/
emitPluginEvent(sourceId: string, event: string, payload: unknown): void {
if (!this.capabilityList(sourceId, 'emits').includes(event)) {
throw new ForbiddenResource(`plugin ${sourceId} does not declare event "${event}"`);
}
for (const subscriberId of this.supervisor.subscribersOf(sourceId, event)) {
if (!this.dependsOnSatisfied(subscriberId, sourceId)) continue;
this.supervisor
.invoke(subscriberId, 'invoke.pluginEvent', { source: sourceId, event, payload }, { actingUserId: undefined, timeoutMs: 5000 })
.catch(() => {
/* a subscriber that throws/times out must not affect the emitter or peers */
});
}
}
/** True if `caller` declares `target` as a plugin dependency whose range the
* installed target version satisfies. */
private dependsOnSatisfied(callerId: string, targetId: string): boolean {
const caller = db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
const target = db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
if (!caller || !target) return false;
const dep = parseDependencies(caller.dependencies).pluginDependencies.find((d) => d.id === targetId);
if (!dep) return false;
return semver.satisfies(target.version ?? '0.0.0', dep.version, { includePrerelease: true });
}
/** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
const row = db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
try {
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
const v = c[field];
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
} }
function parseArray(json: string): string[] { function parseArray(json: string): string[] {
@@ -15,7 +15,7 @@ interface ActivePlugin {
name: string; name: string;
type: string; type: string;
icon: string | null; icon: string | null;
slot: 'sidebar' | 'hero'; slot: 'sidebar' | 'hero' | 'place-detail';
} }
@Controller('api/plugins') @Controller('api/plugins')
@@ -32,10 +32,11 @@ export class PluginsFeedController {
} }
} }
function slotOf(capabilities: string): 'sidebar' | 'hero' { function slotOf(capabilities: string): 'sidebar' | 'hero' | 'place-detail' {
try { try {
const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } }; const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } };
return c.widget?.slot === 'hero' ? 'hero' : 'sidebar'; const slot = c.widget?.slot;
return slot === 'hero' || slot === 'place-detail' ? slot : 'sidebar';
} catch { } catch {
return 'sidebar'; return 'sidebar';
} }
@@ -22,6 +22,30 @@ import { PluginRuntimeService } from './plugin-runtime.service';
// consumers (the plugin's own client) are unaffected — fetch ignores both. // consumers (the plugin's own client) are unaffected — fetch ignores both.
const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control']); const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control']);
// Origin used only to parse a plugin-supplied redirect target. Any host works as
// long as it can never match a real one, so a target that stays "same-origin"
// after WHATWG URL parsing is provably a relative in-app path.
const REDIRECT_BASE = 'https://trek.invalid';
/**
* Return a plugin redirect `Location` ONLY if it is a same-origin (relative)
* target, normalised to path+query+hash; otherwise `null`. Parsing (rather than
* regex-matching the raw string) is what makes this safe against the open-redirect
* bypasses browsers introduce for special schemes: `\` normalised to `/`, stripped
* tab/newline, protocol-relative `//host`, and fully-qualified/other-scheme URLs
* all resolve to a different origin and are rejected.
*/
function toRelativeLocation(loc: unknown): string | null {
if (typeof loc !== 'string' || loc === '') return null;
try {
const u = new URL(loc, REDIRECT_BASE);
if (u.origin !== REDIRECT_BASE) return null;
return u.pathname + u.search + u.hash;
} catch {
return null;
}
}
@Controller('api/plugins/:pluginId') @Controller('api/plugins/:pluginId')
export class PluginsProxyController { export class PluginsProxyController {
constructor(private readonly runtime: PluginRuntimeService) {} constructor(private readonly runtime: PluginRuntimeService) {}
@@ -82,11 +106,17 @@ export class PluginsProxyController {
// redirects and fall through to the normal path. // redirects and fall through to the normal path.
if ([301, 302, 303, 307, 308].includes(status)) { if ([301, 302, 303, 307, 308].includes(status)) {
const loc = Object.entries(headers).find(([k]) => k.toLowerCase() === 'location')?.[1]; const loc = Object.entries(headers).find(([k]) => k.toLowerCase() === 'location')?.[1];
if (typeof loc !== 'string' || !/^\/(?!\/)/.test(loc)) { // Resolve against a throwaway origin and require the result to stay on it:
// only a same-origin (relative) target survives. A string check can't do
// this safely — browsers normalise `\` to `/` and strip tab/newline for
// http(s), so `/\evil.com`, `/<tab>/evil.com`, `//evil.com`, and any
// absolute URL all resolve off-origin and must be rejected.
const safeLoc = toRelativeLocation(loc);
if (!safeLoc) {
res.status(502).json({ error: 'Plugin error', detail: 'unsafe redirect target' }); res.status(502).json({ error: 'Plugin error', detail: 'unsafe redirect target' });
return; return;
} }
res.status(status).setHeader('Location', loc); res.status(status).setHeader('Location', safeLoc);
res.end(); res.end();
return; return;
} }
+37 -7
View File
@@ -1,6 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { PluginsService } from './plugins.service'; import { PluginsService } from './plugins.service';
import { PluginRuntimeService, PluginConsentRequired } from './plugin-runtime.service'; import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
import { DependencyCycleError } from './dependencies';
import { PluginRegistryService } from './registry/registry.service'; import { PluginRegistryService } from './registry/registry.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard'; import { AdminGuard } from '../auth/admin.guard';
@@ -29,8 +31,8 @@ export class PluginsController {
} }
@Get('registry') @Get('registry')
browse() { browse(@Query('refresh') refresh?: string) {
return this.registry.browse(); return this.registry.browse(refresh === '1' || refresh === 'true');
} }
@Get('registry/:id') @Get('registry/:id')
@@ -44,16 +46,34 @@ export class PluginsController {
@Post('install') @Post('install')
@HttpCode(200) @HttpCode(200)
async install(@Body() body: { id?: string; version?: string }) { async install(@Body() body: { id?: string; version?: string; constraint?: string; withDependencies?: boolean }) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400); if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
try { try {
return await this.registry.install(body.id, body.version); // withDependencies (used by the "resolve missing dependency" admin flow) pulls
// the target + its transitive plugin deps, resolving each to its latest
// compatible version and reporting addons the admin still has to enable.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
} catch (e) { } catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400); throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
} }
} }
/** Sideload a plugin from an uploaded .zip/.tar.gz (registers INACTIVE). */
@Post('upload')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 + 4096 } }))
async upload(@UploadedFile() file?: Express.Multer.File) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!file?.buffer?.length) throw new HttpException({ error: 'no file uploaded' }, 400);
try {
return await this.runtime.sideload(file.buffer);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'upload failed' }, 400);
}
}
@Get(':id/config') @Get(':id/config')
getConfig(@Param('id') id: string) { getConfig(@Param('id') id: string) {
return { config: this.plugins.getInstanceConfig(id) }; return { config: this.plugins.getInstanceConfig(id) };
@@ -76,6 +96,14 @@ export class PluginsController {
if (e instanceof PluginConsentRequired) { if (e instanceof PluginConsentRequired) {
throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409); throw new HttpException({ error: e.message, code: 'CONSENT_REQUIRED', newPermissions: e.newPermissions, newEgress: e.newEgress }, 409);
} }
// Unmet dependency (disabled addon / missing / version-mismatched plugin) —
// the UI offers the right fix (enable addon, or download the dependency).
if (e instanceof PluginDependencyError) {
throw new HttpException({ error: e.message, code: e.code, ...e.detail }, 409);
}
if (e instanceof DependencyCycleError) {
throw new HttpException({ error: e.message, code: 'DEPENDENCY_CYCLE', cyclePath: e.cyclePath }, 409);
}
throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400); throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400);
} }
return { status: this.runtime.isActive(id) ? 'active' : 'error' }; return { status: this.runtime.isActive(id) ? 'active' : 'error' };
@@ -84,7 +112,9 @@ export class PluginsController {
@Post(':id/deactivate') @Post(':id/deactivate')
@HttpCode(200) @HttpCode(200)
async deactivate(@Param('id') id: string) { async deactivate(@Param('id') id: string) {
await this.runtime.deactivate(id); // Cascade: disabling a plugin also disables everything that depends on it (a
// dependent can't run without its dependency). The client refresh reflects it.
await this.runtime.deactivateWithDependents(id);
return { status: 'inactive' }; return { status: 'inactive' };
} }
+6 -1
View File
@@ -3,6 +3,8 @@ import { PluginsController } from './plugins.controller';
import { PluginsFeedController } from './plugins-feed.controller'; import { PluginsFeedController } from './plugins-feed.controller';
import { PluginsProxyController } from './plugins-proxy.controller'; import { PluginsProxyController } from './plugins-proxy.controller';
import { PluginFrameController } from './plugin-frame.controller'; import { PluginFrameController } from './plugin-frame.controller';
import { PlaceDetailsController } from './place-details.controller';
import { TripWarningsController } from './trip-warnings.controller';
import { PluginsService } from './plugins.service'; import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service'; import { PluginRuntimeService } from './plugin-runtime.service';
import { PluginRegistryService } from './registry/registry.service'; import { PluginRegistryService } from './registry/registry.service';
@@ -15,7 +17,10 @@ import { PluginRegistryService } from './registry/registry.service';
* widget assets at /plugin-frame/:id/*. * widget assets at /plugin-frame/:id/*.
*/ */
@Module({ @Module({
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService], providers: [PluginsService, PluginRuntimeService, PluginRegistryService],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
// required addon was just turned off (#plugins dependencies).
exports: [PluginRuntimeService],
}) })
export class PluginsModule {} export class PluginsModule {}
+51 -3
View File
@@ -3,9 +3,14 @@ import { db } from '../../db/database';
import { pluginsEnabled } from './kill-switch'; import { pluginsEnabled } from './kill-switch';
import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto'; import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto';
import { readAudit } from './host/plugin-audit'; import { readAudit } from './host/plugin-audit';
import { isAddonEnabled } from '../../services/adminService';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
import type { PluginDependency } from './install/manifest';
const SECRET_MASK = '••••••••'; const SECRET_MASK = '••••••••';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin';
/** /**
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed * Read side of the plugin system (#plugins), M0 scaffold. Lists installed
* plugins from the `plugins` registry table and reports whether the runtime is * plugins from the `plugins` registry table and reports whether the runtime is
@@ -13,6 +18,23 @@ const SECRET_MASK = '••••••••';
* install pipeline and registry fetch land in later milestones. * install pipeline and registry fetch land in later milestones.
*/ */
interface PluginRawRow {
id: string;
name: string;
description: string | null;
type: string;
icon: string | null;
version: string | null;
status: string;
enabled: number;
last_error: string | null;
reviewed_at: string | null;
source_repo: string | null;
permissions: string;
capabilities: string;
dependencies: string | null;
}
export interface PluginListItem { export interface PluginListItem {
id: string; id: string;
name: string; name: string;
@@ -29,19 +51,45 @@ export interface PluginListItem {
permissions: string; permissions: string;
/** Declared capabilities (JSON string) — e.g. widget slot. */ /** Declared capabilities (JSON string) — e.g. widget slot. */
capabilities: string; capabilities: string;
/** Declared dependencies (parsed) — required addons + plugin deps. */
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
dependencyStatus: PluginDependencyStatus;
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
} }
@Injectable() @Injectable()
export class PluginsService { export class PluginsService {
list(): { enabled: boolean; plugins: PluginListItem[] } { list(): { enabled: boolean; plugins: PluginListItem[] } {
const plugins = db const rows = db
.prepare( .prepare(
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo, `SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
permissions, capabilities permissions, capabilities, dependencies
FROM plugins FROM plugins
ORDER BY sort_order, name`, ORDER BY sort_order, name`,
) )
.all() as PluginListItem[]; .all() as PluginRawRow[];
const installed = new Map<string, PluginDepRow>(
rows.map((r) => [r.id, { id: r.id, version: r.version, enabled: r.enabled, dependencies: r.dependencies }]),
);
const plugins: PluginListItem[] = rows.map((r) => {
const deps = parseDependencies(r.dependencies);
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
const state = resolveDependencyState(deps, installed);
const dependencyStatus: PluginDependencyStatus = disabledAddons.length
? 'addonDisabled'
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const { dependencies: _raw, ...rest } = r;
return {
...rest,
dependencies: deps,
dependencyStatus,
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
};
});
return { enabled: pluginsEnabled(), plugins }; return { enabled: pluginsEnabled(), plugins };
} }
@@ -53,6 +53,26 @@ export const KNOWN_METHODS = [
'trips.getById', 'trips.getById',
'trips.getPlaces', 'trips.getPlaces',
'trips.getReservations', 'trips.getReservations',
'packing.list',
'files.list',
'costs.getByTrip',
'costs.listMine',
'costs.create',
'costs.update',
'costs.delete',
'places.create',
'places.update',
'places.delete',
'days.create',
'days.update',
'days.delete',
'itinerary.assign',
'itinerary.unassign',
'trips.update',
'meta.get',
'meta.set',
'meta.list',
'meta.delete',
'users.getById', 'users.getById',
'ws.broadcastToTrip', 'ws.broadcastToTrip',
'ws.broadcastToUser', 'ws.broadcastToUser',
@@ -67,6 +87,26 @@ export const METHOD_PERMISSION: Record<KnownMethod, string> = {
'trips.getById': 'db:read:trips', 'trips.getById': 'db:read:trips',
'trips.getPlaces': 'db:read:trips', 'trips.getPlaces': 'db:read:trips',
'trips.getReservations': 'db:read:trips', 'trips.getReservations': 'db:read:trips',
'packing.list': 'db:read:packing',
'files.list': 'db:read:files',
'costs.getByTrip': 'db:read:costs',
'costs.listMine': 'db:read:costs',
'costs.create': 'db:write:costs',
'costs.update': 'db:write:costs',
'costs.delete': 'db:write:costs',
'places.create': 'db:write:places',
'places.update': 'db:write:places',
'places.delete': 'db:write:places',
'days.create': 'db:write:days',
'days.update': 'db:write:days',
'days.delete': 'db:write:days',
'itinerary.assign': 'db:write:itinerary',
'itinerary.unassign': 'db:write:itinerary',
'trips.update': 'db:write:trips',
'meta.get': 'db:meta',
'meta.set': 'db:meta',
'meta.list': 'db:meta',
'meta.delete': 'db:meta',
'users.getById': 'db:read:users', 'users.getById': 'db:read:users',
'ws.broadcastToTrip': 'ws:broadcast:trip', 'ws.broadcastToTrip': 'ws:broadcast:trip',
'ws.broadcastToUser': 'ws:broadcast:user', 'ws.broadcastToUser': 'ws:broadcast:user',
@@ -77,10 +117,22 @@ export const KNOWN_PERMISSIONS = [
'db:own', 'db:own',
'db:read:trips', 'db:read:trips',
'db:read:users', '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:trip',
'ws:broadcast:user', 'ws:broadcast:user',
'hook:photo-provider', 'hook:photo-provider',
'hook:calendar-source', 'hook:calendar-source',
'hook:place-detail-provider',
'hook:trip-warning-provider',
'events:subscribe',
'http:outbound', 'http:outbound',
] as const; ] as const;
@@ -1,7 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import semver from 'semver';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { db } from '../../../db/database'; import { db } from '../../../db/database';
import type { PluginDependency } from '../install/manifest';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths'; import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { safeDownload, sha256Matches } from '../install/safe-fetch'; import { safeDownload, sha256Matches } from '../install/safe-fetch';
import { verifyAuthorSignature } from '../install/verify-signature'; import { verifyAuthorSignature } from '../install/verify-signature';
@@ -24,6 +26,8 @@ const REGISTRY_URL =
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json'; 'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
const CACHE_TTL = 30 * 60 * 1000; const CACHE_TTL = 30 * 60 * 1000;
const MANIFEST_MAX_BYTES = 256 * 1024; const MANIFEST_MAX_BYTES = 256 * 1024;
// Sideload upload ceiling — matches the SDK `pack` limit (50 MB) plus zip overhead.
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 + 4096;
interface RegistryVersion { interface RegistryVersion {
version: string; version: string;
@@ -39,6 +43,10 @@ interface RegistryVersion {
publishedAt?: string; publishedAt?: string;
/** base64 minisign signature (.minisig payload) over the artifact bytes. */ /** base64 minisign signature (.minisig payload) over the artifact bytes. */
signature?: string; signature?: string;
/** Addon ids this version requires enabled (mirrors the manifest; #plugins deps). */
requiredAddons?: string[];
/** Other plugins this version depends on (mirrors the manifest). */
pluginDependencies?: PluginDependency[];
} }
export interface RegistryEntry { export interface RegistryEntry {
id: string; id: string;
@@ -67,6 +75,8 @@ export interface ManifestPreview {
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>; settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>;
license: string | null; license: string | null;
icon: string | null; icon: string | null;
requiredAddons: string[];
pluginDependencies: PluginDependency[];
} }
let _cache: { data: Registry; expiresAt: number } | null = null; let _cache: { data: Registry; expiresAt: number } | null = null;
@@ -82,11 +92,20 @@ export class RegistryError extends Error {}
@Injectable() @Injectable()
export class PluginRegistryService { export class PluginRegistryService {
/** Fetch the aggregated registry (cached, soft-fail, stale-serve). */ /**
async fetchRegistry(): Promise<Registry> { * Fetch the aggregated registry (cached, soft-fail, stale-serve). Pass
if (_cache && Date.now() < _cache.expiresAt) return _cache.data; * force=true (the admin "rescan" button) to bypass the 30-min cache and also
* bust GitHub's raw CDN edge cache (max-age=300), so a just-merged registry
* entry shows up right away instead of up to ~35 min later.
*/
async fetchRegistry(force = false): Promise<Registry> {
if (!force && _cache && Date.now() < _cache.expiresAt) return _cache.data;
if (force) _detailCache.clear();
try { try {
const resp = await fetch(REGISTRY_URL, { headers: { 'User-Agent': 'TREK-Server' } }); const url = force ? `${REGISTRY_URL}${REGISTRY_URL.includes('?') ? '&' : '?'}_=${Date.now()}` : REGISTRY_URL;
const headers: Record<string, string> = { 'User-Agent': 'TREK-Server' };
if (force) { headers['Cache-Control'] = 'no-cache'; headers.Pragma = 'no-cache'; }
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`registry ${resp.status}`); if (!resp.ok) throw new Error(`registry ${resp.status}`);
const data = (await resp.json()) as Registry; const data = (await resp.json()) as Registry;
if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry'); if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry');
@@ -98,14 +117,15 @@ export class PluginRegistryService {
} }
/** The browse list the admin UI renders (metadata only, no code). */ /** The browse list the admin UI renders (metadata only, no code). */
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; screenshotUrl: string | null }>> { async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null }>> {
const reg = await this.fetchRegistry(); const reg = await this.fetchRegistry(force);
return reg.plugins.map((p) => { return reg.plugins.map((p) => {
const latest = p.versions[0] ?? null; const latest = p.versions[0] ?? null;
return { return {
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo, id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null, homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null, latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null, screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
}; };
}); });
@@ -144,6 +164,7 @@ export class PluginRegistryService {
reviewedAt: entry.reviewedAt ?? null, reviewedAt: entry.reviewedAt ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null, latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null, size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null, screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
manifest, manifest,
}; };
@@ -153,13 +174,42 @@ export class PluginRegistryService {
return data; return data;
} }
/** Install a pinned version from the registry. Returns the installed plugin id. */ /**
async install(id: string, version?: string): Promise<{ id: string; version: string }> { * Resolve which registry version of `id` to install: the highest that satisfies
* `constraint` (any version if omitted) AND is compatible with the running TREK
* version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the
* registry or nothing qualifies. Backs "download the latest compatible version".
*/
async resolveVersion(id: string, constraint?: string): Promise<RegistryVersion> {
const reg = await this.fetchRegistry(); const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id); const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`); if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const ver = version ? entry.versions.find((v) => v.version === version) : entry.versions[0]; const host = hostVersion();
if (!ver) throw new RegistryError(`version ${version ?? 'latest'} not found for ${id}`); const candidates = entry.versions.filter((v) => {
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
return hostCompatible(v, host);
});
if (!candidates.length) {
throw new RegistryError(
constraint
? `no version of ${id} satisfies "${constraint}" and this TREK version`
: `no compatible version of ${id} for this TREK version`,
);
}
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
}
/** Install a version from the registry. Returns the installed plugin id + version. */
async install(id: string, opts?: { version?: string; constraint?: string }): Promise<{ id: string; version: string }> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const ver = opts?.version
? entry.versions.find((v) => v.version === opts.version)
: opts?.constraint
? await this.resolveVersion(id, opts.constraint)
: entry.versions[0];
if (!ver) throw new RegistryError(`version ${opts?.version ?? opts?.constraint ?? 'latest'} not found for ${id}`);
// 1. SSRF-safe download + 2. sha256 verify // 1. SSRF-safe download + 2. sha256 verify
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096); const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
@@ -203,6 +253,85 @@ export class PluginRegistryService {
} }
} }
/**
* Install `id` (latest-compatible, or the newest matching `constraint`) together
* with any of its declared plugin dependencies that aren't installed yet — each
* resolved to the newest version satisfying its declared range. Required addons
* can't be installed; they're collected and returned so the caller can prompt the
* admin to enable them. Cycle-safe. Already-installed plugins are left untouched.
*/
async installWithDependencies(id: string, constraint?: string): Promise<{ installed: string[]; requiredAddons: string[] }> {
const installedNow = new Set((db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id));
const done = new Set<string>();
const installed: string[] = [];
const requiredAddons = new Set<string>();
const visit = async (pid: string, range: string | undefined, stack: string[]): Promise<void> => {
if (done.has(pid)) return;
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
if (!installedNow.has(pid)) {
await this.install(pid, range ? { constraint: range } : undefined);
installed.push(pid);
installedNow.add(pid);
}
done.add(pid);
for (const a of ver.requiredAddons ?? []) requiredAddons.add(a);
for (const dep of ver.pluginDependencies ?? []) await visit(dep.id, dep.version, [...stack, pid]);
};
await visit(id, constraint, []);
return { installed, requiredAddons: [...requiredAddons] };
}
/**
* Sideload step 1: extract + validate an uploaded archive into a staging dir,
* WITHOUT touching the live plugin. Returns the manifest id/version + the staged
* path so the caller can stop a running child before the swap. Same hard guards
* as a registry install (slip/bomb-safe extract, strict manifest, no native
* binaries) — only the registry sha256/signature checks are absent, because a
* sideload has no registry entry. Throws (and self-cleans staging) on failure.
*/
stageUpload(bytes: Buffer): { id: string; version: string; root: string; stagingDir: string } {
if (bytes.length > MAX_UPLOAD_BYTES) throw new RegistryError('archive exceeds the 50MB limit');
const stagingDir = path.join(pluginsDataRoot(), '.staging', `upload-${Date.now()}`);
try {
extractArchive(bytes, stagingDir);
const root = locateManifestDir(stagingDir);
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')));
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
return { id: manifest.id, version: manifest.version, root, stagingDir };
} catch (e) {
fs.rmSync(stagingDir, { recursive: true, force: true });
throw e;
}
}
/**
* Sideload step 2: move a staged upload into place and register it INACTIVE as a
* sideloaded plugin — source "local:upload", unsigned, not registry-reviewed, so
* the UI flags it and offers no auto-update. The caller MUST have stopped any
* running child of this id first (the code dir is replaced).
*/
commitUpload(staged: { id: string; root: string; stagingDir: string }): void {
try {
const dest = pluginCodeDir(staged.id);
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staged.root, dest);
discoverPlugins(db);
// Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps
// an existing row's status, so replacing a plugin that was active must not
// leave the new code marked active — the admin re-activates (and re-consents
// to permissions) explicitly.
db.prepare("UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?").run(
'local:upload', null, null, null, staged.id,
);
} finally {
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
}
}
/** /**
* Verify the author signature (if the entry declares a key + the version a * Verify the author signature (if the entry declares a key + the version a
* signature) and enforce Trust-On-First-Use on the author key. * signature) and enforce Trust-On-First-Use on the author key.
@@ -237,6 +366,20 @@ function rawFileUrl(repo: string, commitSha: string, file: string): string {
return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`; return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`;
} }
/** The running TREK version (same source as the rest of the app). */
function hostVersion(): string {
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
}
/** Whether a registry version's host-version bounds admit the running TREK. */
function hostCompatible(v: RegistryVersion, host: string): boolean {
const h = semver.coerce(host)?.version ?? host;
if (!semver.valid(h)) return true; // unparseable host — don't block on compat
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(h, v.minTrekVersion)) return false;
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(h, v.maxTrekVersion)) return false;
return true;
}
/** /**
* Tolerant projection of a live manifest for display. Unknown shapes degrade to * Tolerant projection of a live manifest for display. Unknown shapes degrade to
* empty lists instead of throwing — a future manifest field must never break * empty lists instead of throwing — a future manifest field must never break
@@ -258,12 +401,20 @@ function previewManifest(raw: unknown): ManifestPreview {
})) }))
.filter((s) => s.key) .filter((s) => s.key)
: []; : [];
const pluginDependencies = Array.isArray(m.pluginDependencies)
? m.pluginDependencies
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object')
.map((d) => ({ id: typeof d.id === 'string' ? d.id : '', version: typeof d.version === 'string' ? d.version : '' }))
.filter((d) => d.id && d.version)
: [];
return { return {
permissions: strings(m.permissions), permissions: strings(m.permissions),
egress: strings(m.egress), egress: strings(m.egress),
settings, settings,
license: typeof m.license === 'string' ? m.license : null, license: typeof m.license === 'string' ? m.license : null,
icon: typeof m.icon === 'string' ? m.icon : null, icon: typeof m.icon === 'string' ? m.icon : null,
requiredAddons: strings(m.requiredAddons),
pluginDependencies,
}; };
} }
@@ -23,12 +23,20 @@ const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown';
const pluginDir = process.argv[3] || ''; const pluginDir = process.argv[3] || '';
let pluginConfig: Record<string, unknown> = {}; let pluginConfig: Record<string, unknown> = {};
// Guards `init` against re-entry (see the message handler). Set the first time a
// legitimate `init` arrives from the host.
let initialized = false;
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>(); const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
let seq = 0; let seq = 0;
// Capture the raw IPC write ONCE, before installIpcGuard() locks `process.send`
// to a throwing stub. The transport keeps sending through this closure; plugin
// code no longer has any path to the channel. See installIpcGuard().
const realSend: (msg: Envelope) => void =
typeof process.send === 'function' ? process.send.bind(process) : () => {};
function send(msg: Envelope): void { function send(msg: Envelope): void {
process.send?.(msg); realSend(msg);
} }
const transport: ChildTransport = { const transport: ChildTransport = {
@@ -73,6 +81,10 @@ function installSdkInjection(requirePlugin: NodeJS.Require): void {
async function boot(config: Record<string, unknown>): Promise<void> { async function boot(config: Record<string, unknown>): Promise<void> {
try { try {
// Seal the raw IPC surface BEFORE any plugin code is required — this is the
// first moment untrusted code runs (requirePlugin below). After this, the
// plugin can only reach the host through the capability-checked SDK.
installIpcGuard();
// createRequire works whether this bootstrap runs as CJS (prod dist) or ESM // createRequire works whether this bootstrap runs as CJS (prod dist) or ESM
// (tsx in tests), so `require` being undefined in ESM never bites us. // (tsx in tests), so `require` being undefined in ESM never bites us.
const entry = path.join(pluginDir, 'server', 'index.js'); const entry = path.join(pluginDir, 'server', 'index.js');
@@ -86,7 +98,13 @@ async function boot(config: Record<string, unknown>): Promise<void> {
// host can proxy HTTP and schedule jobs without re-parsing the manifest. // host can proxy HTTP and schedule jobs without re-parsing the manifest.
const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false })); const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false }));
const jobs = (def.jobs ?? []).map((j) => j.id); const jobs = (def.jobs ?? []).map((j) => j.id);
send({ k: 'evt', topic: 'loaded', data: { routes, jobs } }); const hooks = Object.keys((def.hooks ?? {}) as Record<string, unknown>);
const events = (def.events ?? []).map((e) => e.on);
// Inter-plugin surface: the callable exports this plugin implements, and the
// other-plugin events it subscribes to (so the host can route fan-out).
const exportNames = Object.keys((def.exports ?? {}) as Record<string, unknown>);
const subscriptions = (def.subscriptions ?? []).map((s) => ({ plugin: s.plugin, event: s.event }));
send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, events, exports: exportNames, subscriptions } });
// An immediate first heartbeat confirms liveness without waiting a full interval. // An immediate first heartbeat confirms liveness without waiting a full interval.
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } }); send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
} catch (e) { } catch (e) {
@@ -120,6 +138,50 @@ async function handleInvoke(req: { id: string; method: string; params: Record<st
if (!job) throw new Error(`no job ${jobId}`); if (!job) throw new Error(`no job ${jobId}`);
await job.handler(invCtx); await job.handler(invCtx);
respond(true, { ok: true }); respond(true, { ok: true });
} else if (req.method === 'invoke.hook') {
// Host→plugin provider call: core asks a hook the plugin implements (e.g.
// placeDetailProvider) for data. The hook method gets its args + the per-
// invocation ctx (so any trip reads it makes bind to the authenticated user).
const hookName = req.params.hook as string;
const fnName = req.params.fn as string;
const args = (req.params.args as unknown[]) ?? [];
const hooks = def.hooks as Record<string, Record<string, (...a: unknown[]) => unknown> | undefined> | undefined;
const impl = hooks?.[hookName];
if (!impl || typeof impl[fnName] !== 'function') throw new Error(`no hook ${hookName}.${fnName}`);
const result = await impl[fnName](...args, invCtx);
respond(true, result);
} else if (req.method === 'invoke.event') {
// A core event fired for a trip. Run every matching subscription. invCtx carries
// NO user (delivered like a job), so trip reads are refused — the handler reacts
// to the fact of the event, using the plugin's own db / outbound / broadcasts.
const eventName = req.params.event as string;
const tripId = req.params.tripId as number;
const payload = { event: eventName, tripId };
for (const sub of def.events ?? []) {
if (sub.on === '*' || sub.on === eventName) await sub.handler(payload, invCtx);
}
respond(true, { ok: true });
} else if (req.method === 'invoke.export') {
// Another plugin (a declared dependent) called one of our exports. The host has
// already authorized the caller + the export name; run it with the per-invocation
// ctx so any trip reads bind to the CALLER's acting user (propagated by the host).
const fnName = req.params.fn as string;
const args = req.params.args;
const impl = (def.exports ?? {})[fnName];
if (typeof impl !== 'function') throw new Error(`no export ${fnName}`);
const result = await impl(args, invCtx);
respond(true, result);
} else if (req.method === 'invoke.pluginEvent') {
// Another plugin (a declared dependency of ours) emitted an event we subscribed
// to. Run every matching subscription. invCtx carries NO user (fire-and-forget),
// like a core event, but delivers the emitter's payload.
const source = req.params.source as string;
const eventName = req.params.event as string;
const payload = req.params.payload;
for (const sub of def.subscriptions ?? []) {
if (sub.plugin === source && sub.event === eventName) await sub.handler(payload, invCtx);
}
respond(true, { ok: true });
} else { } else {
respond(false, `unknown invoke ${req.method}`); respond(false, `unknown invoke ${req.method}`);
} }
@@ -160,6 +222,11 @@ process.on('message', (raw: unknown) => {
} }
if (msg.k === 'evt') { if (msg.k === 'evt') {
if (msg.topic === 'init') { if (msg.topic === 'init') {
// One-shot: the host sends `init` exactly once. Refuse any second `init`
// so a forged one (e.g. a pre-seal race) can't re-run installEgressGuard
// with a widened egress list or re-boot the plugin.
if (initialized) return;
initialized = true;
const d = msg.data as { config?: Record<string, unknown>; egress?: string[] }; const d = msg.data as { config?: Record<string, unknown>; egress?: string[] };
pluginConfig = d.config ?? {}; pluginConfig = d.config ?? {};
installEgressGuard(d.egress ?? []); installEgressGuard(d.egress ?? []);
@@ -168,6 +235,82 @@ process.on('message', (raw: unknown) => {
} }
}); });
/**
* Sever the raw host<->child IPC surface from plugin code. The plugin runs in
* THIS process and shares the global `process`, so without this it could bypass
* the SDK entirely and talk to the host directly:
* - `process.send(...)` — forge lifecycle/RPC envelopes (fake `loaded`/`heartbeat`,
* or a `req` carrying another invocation's `_inv` to act as that user).
* - `process.on('message', ...)` — eavesdrop on every host→child `req`/`res`
* (other invocations' request bodies, user objects, DB results).
* - `process.removeAllListeners('message')` — kill the trusted handler and hijack.
*
* We do NOT override `process.emit`: Node delivers inbound IPC by calling
* `process.emit('message', ...)` internally, so a throwing override would break
* message delivery to the trusted listener. A plugin synthesizing
* `process.emit('message', forged)` is instead defanged elsewhere — a forged
* `init` is ignored by the one-shot `initialized` guard, and a forged `req`/`res`/
* `shutdown` only affects the plugin's own process (self-harm, no host impact).
*
* The transport keeps working because `send()` uses the captured `realSend`
* closure, and the trusted message listener + crash handlers are already
* registered at module-eval (before this runs). Mirrors the `process.binding`
* lock in installEgressGuard: same `Object.defineProperty(..., writable:false,
* configurable:false)` idiom, same "already locked" swallow. This is a JS-level
* defense-in-depth layer; in prod the OS `--permission` model is the real jail.
*
* MUST run once, before the plugin module is required.
*/
function installIpcGuard(): void {
const lock = (obj: object, key: string, value: unknown): void => {
try {
Object.defineProperty(obj, key, { value, writable: false, configurable: false });
} catch {
/* already locked / non-configurable */
}
};
// Outbound: kill the raw write and channel teardown. `realSend` (captured
// above) is unaffected — it holds the bound original, not `process.send`.
lock(process, 'send', () => {
throw new Error('ipc: process.send is disabled for plugins');
});
lock(process, 'disconnect', () => {
throw new Error('ipc: process.disconnect is disabled for plugins');
});
// NOTE: we intentionally do NOT lock `process.channel`/`process._channel`.
// Node's own outbound send (`realSend`, the bound original) reads the pipe
// handle back off `process`, so overriding it to `undefined` breaks the
// legitimate transport (verified: child→host RPC hangs). Without a working
// `process.send`, framing raw IPC through the handle directly is impractical,
// so leaving it readable costs nothing.
// Inbound: the trusted 'message' listener is already installed. Forbid the
// plugin adding/removing/replacing a 'message' listener so it can't eavesdrop
// on host→child traffic or unhook the trusted handler. Only 'message'/
// 'internalMessage' are gated — plugins may still use other process events
// (e.g. 'SIGTERM', 'beforeExit'). `emit` is deliberately NOT gated (Node uses
// it to deliver inbound IPC; see the header comment).
const GUARDED = new Set(['message', 'internalMessage']);
const ee = process as unknown as Record<string, (...a: unknown[]) => unknown>;
const emitterMethods = [
'on', 'addListener', 'prependListener', 'once', 'prependOnceListener',
'off', 'removeListener', 'removeAllListeners',
] as const;
for (const m of emitterMethods) {
const real = ee[m].bind(process);
lock(process, m, (event: unknown, ...rest: unknown[]) => {
// `removeAllListeners()` with no event would also strip the trusted
// 'message' listener (and the crash handlers), so block the bare form.
const bareRemoveAll = m === 'removeAllListeners' && event === undefined;
if (bareRemoveAll || (typeof event === 'string' && GUARDED.has(event))) {
throw new Error(`ipc: process.${m}('${String(event)}') is disabled for plugins`);
}
return real(event, ...rest);
});
}
}
/** /**
* Restrict the plugin's outbound network to its declared egress hosts. With no * Restrict the plugin's outbound network to its declared egress hosts. With no
* declared egress, ALL outbound is blocked. Wildcards like `*.host` match any * declared egress, ALL outbound is blocked. Wildcards like `*.host` match any
+152 -2
View File
@@ -28,6 +28,54 @@ export interface PluginContext {
getById(tripId: number, asUserId?: number): Promise<unknown>; getById(tripId: number, asUserId?: number): Promise<unknown>;
getPlaces(tripId: number, asUserId?: number): Promise<unknown[]>; getPlaces(tripId: number, asUserId?: number): Promise<unknown[]>;
getReservations(tripId: number, asUserId?: number): Promise<unknown[]>; getReservations(tripId: number, asUserId?: number): Promise<unknown[]>;
/** Update trip fields (title/dates/currency/reminder_days/...); needs 'db:write:trips' + the acting user's 'trip_edit' permission. */
update(tripId: number, input: Record<string, unknown>): Promise<unknown>;
};
// Read-only views of other trip subsystems (#1429 eco). Membership-checked like
// `trips`; each needs its own db:read:* scope.
packing: {
/** A trip's packing items (hydrated bags/assignees). Needs 'db:read:packing'. */
list(tripId: number): Promise<unknown[]>;
};
files: {
/** A trip's files, trash excluded. Needs 'db:read:files'. */
list(tripId: number): Promise<unknown[]>;
};
// "Costs" = budget items. Reads are membership-checked against the current
// invocation's user (like `trips`); `create` additionally needs the acting
// user's 'budget_edit' permission and the Costs addon enabled.
costs: {
getByTrip(tripId: number): Promise<unknown[]>;
listMine(): Promise<unknown[]>;
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<unknown>;
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
};
// Core planner writes (#1429). Each is membership-checked against the current
// invocation's user and needs the matching write scope + the app's edit
// permission (place_edit / day_edit / trip_edit). Route context only.
places: {
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
update(tripId: number, placeId: number, input: Record<string, unknown>): Promise<unknown>;
delete(tripId: number, placeId: number): Promise<{ deleted: boolean }>;
};
days: {
create(tripId: number, input: Record<string, unknown>): Promise<unknown>;
update(tripId: number, dayId: number, input: Record<string, unknown>): Promise<unknown>;
delete(tripId: number, dayId: number): Promise<{ deleted: boolean }>;
};
itinerary: {
assign(tripId: number, dayId: number, placeId: number, notes?: string | null): Promise<unknown>;
unassign(tripId: number, assignmentId: number): Promise<{ deleted: boolean }>;
};
// Your OWN namespaced key/value store attached to a trip/place/day (#1429), so
// you can enrich core entities without forking the schema. The entity must belong
// to a trip the current user can access. Values are JSON-serialisable.
meta: {
get(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<unknown>;
set(entityType: 'trip' | 'place' | 'day', entityId: number, key: string, value: unknown): Promise<unknown>;
list(entityType: 'trip' | 'place' | 'day', entityId: number): Promise<Record<string, unknown>>;
delete(entityType: 'trip' | 'place' | 'day', entityId: number, key: string): Promise<{ deleted: boolean }>;
}; };
users: { users: {
getById(id: number): Promise<unknown>; getById(id: number): Promise<unknown>;
@@ -41,6 +89,16 @@ export interface PluginContext {
warn(msg: string, meta?: Record<string, unknown>): void; warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void; error(msg: string, meta?: Record<string, unknown>): void;
}; };
/** Call a function another plugin exposes (must be a declared, satisfied dependency
* that lists `fn` in its manifest `capabilities.provides`). Runs as the current user. */
plugins: {
call(pluginId: string, fn: string, args?: unknown): Promise<unknown>;
};
/** Publish an event to dependents that subscribed to it (must be declared in this
* plugin's manifest `capabilities.emits`). Fire-and-forget. */
events: {
emit(name: string, payload?: unknown): void;
};
} }
export interface PluginRequest { export interface PluginRequest {
@@ -66,12 +124,64 @@ export interface PluginJob {
schedule: string; schedule: string;
handler(ctx: PluginContext): Promise<void>; handler(ctx: PluginContext): Promise<void>;
} }
// ── Provider hooks (host→plugin): core asks a hook the plugin implements for data,
// gated by the matching hook:* permission. Each method also receives the per-
// invocation ctx, so any trip reads it makes bind to the authenticated user. ──
export interface Photo { id: string; title?: string; thumbnailUrl: string; fullUrl: string; takenAt?: string; }
export interface PhotoProvider {
search(query: string, opts: { page: number; limit: number }): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
getById(id: string): Promise<Photo | null>;
}
export interface CalendarEvent { id: string; title: string; start: string; end: string; allDay: boolean; }
export interface CalendarSource {
getName(): string;
getEvents(userId: number, start: string, end: string): Promise<CalendarEvent[]>;
}
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
export interface PlaceDetailProvider {
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
export interface WarningProvider {
getWarnings(tripId: number, ctx: PluginContext): Promise<TripWarning[]>;
}
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job)
* and receive only the event name + tripId — never the payload. Needs 'events:subscribe'. */
export interface PluginEventSubscription {
on: string; // a core event name (e.g. 'place:created', 'day:updated') or '*' for all
handler(payload: { event: string; tripId: number }, ctx: PluginContext): Promise<void> | void;
}
/** A function this plugin exposes to its dependents (declared in capabilities.provides). */
export type PluginExport = (args: unknown, ctx: PluginContext) => Promise<unknown> | unknown;
/** A subscription to another plugin's event. Authorized by declaring that plugin as a
* dependency; the handler runs with NO user and receives the emitter's payload. */
export interface PluginSubscription {
plugin: string;
event: string;
handler(payload: unknown, ctx: PluginContext): Promise<void> | void;
}
export interface PluginDefinition { export interface PluginDefinition {
onLoad?(ctx: PluginContext): Promise<void> | void; onLoad?(ctx: PluginContext): Promise<void> | void;
onUnload?(ctx: PluginContext): Promise<void> | void; onUnload?(ctx: PluginContext): Promise<void> | void;
routes?: PluginRoute[]; routes?: PluginRoute[];
jobs?: PluginJob[]; jobs?: PluginJob[];
// hooks (photo/calendar) land in M2 events?: PluginEventSubscription[];
hooks?: {
photoProvider?: PhotoProvider;
calendarSource?: CalendarSource;
placeDetailProvider?: PlaceDetailProvider;
warningProvider?: WarningProvider;
};
/** Functions exposed to dependents (names must match manifest capabilities.provides). */
exports?: Record<string, PluginExport>;
/** Subscriptions to other plugins' events (each `plugin` must be a declared dependency). */
subscriptions?: PluginSubscription[];
} }
/** Identity helper: gives authors types + a stable shape. A plain object works too. */ /** Identity helper: gives authors types + a stable shape. A plain object works too. */
@@ -110,9 +220,43 @@ export function createPluginContext(
getById: (tripId) => t.rpc('trips.getById', { tripId, _inv: invocationId }), getById: (tripId) => t.rpc('trips.getById', { tripId, _inv: invocationId }),
getPlaces: (tripId) => t.rpc('trips.getPlaces', { tripId, _inv: invocationId }) as Promise<unknown[]>, getPlaces: (tripId) => t.rpc('trips.getPlaces', { tripId, _inv: invocationId }) as Promise<unknown[]>,
getReservations: (tripId) => t.rpc('trips.getReservations', { tripId, _inv: invocationId }) as Promise<unknown[]>, getReservations: (tripId) => t.rpc('trips.getReservations', { tripId, _inv: invocationId }) as Promise<unknown[]>,
update: (tripId, input) => t.rpc('trips.update', { tripId, input, _inv: invocationId }),
},
packing: {
list: (tripId) => t.rpc('packing.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
},
files: {
list: (tripId) => t.rpc('files.list', { tripId, _inv: invocationId }) as Promise<unknown[]>,
},
costs: {
getByTrip: (tripId) => t.rpc('costs.getByTrip', { tripId, _inv: invocationId }) as Promise<unknown[]>,
listMine: () => t.rpc('costs.listMine', { _inv: invocationId }) as Promise<unknown[]>,
create: (tripId, input) => t.rpc('costs.create', { tripId, input, _inv: invocationId }),
update: (tripId, itemId, input) => t.rpc('costs.update', { tripId, itemId, input, _inv: invocationId }),
delete: (tripId, itemId) => t.rpc('costs.delete', { tripId, itemId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
},
places: {
create: (tripId, input) => t.rpc('places.create', { tripId, input, _inv: invocationId }),
update: (tripId, placeId, input) => t.rpc('places.update', { tripId, placeId, input, _inv: invocationId }),
delete: (tripId, placeId) => t.rpc('places.delete', { tripId, placeId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
},
days: {
create: (tripId, input) => t.rpc('days.create', { tripId, input, _inv: invocationId }),
update: (tripId, dayId, input) => t.rpc('days.update', { tripId, dayId, input, _inv: invocationId }),
delete: (tripId, dayId) => t.rpc('days.delete', { tripId, dayId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
},
itinerary: {
assign: (tripId, dayId, placeId, notes) => t.rpc('itinerary.assign', { tripId, dayId, placeId, notes, _inv: invocationId }),
unassign: (tripId, assignmentId) => t.rpc('itinerary.unassign', { tripId, assignmentId, _inv: invocationId }) as Promise<{ deleted: boolean }>,
},
meta: {
get: (entityType, entityId, key) => t.rpc('meta.get', { entityType, entityId, key, _inv: invocationId }),
set: (entityType, entityId, key, value) => t.rpc('meta.set', { entityType, entityId, key, value, _inv: invocationId }),
list: (entityType, entityId) => t.rpc('meta.list', { entityType, entityId, _inv: invocationId }) as Promise<Record<string, unknown>>,
delete: (entityType, entityId, key) => t.rpc('meta.delete', { entityType, entityId, key, _inv: invocationId }) as Promise<{ deleted: boolean }>,
}, },
users: { users: {
getById: (uid) => t.rpc('users.getById', { id: uid }), getById: (uid) => t.rpc('users.getById', { id: uid, _inv: invocationId }),
}, },
ws: { ws: {
broadcastToTrip: async (tripId, event, data) => { broadcastToTrip: async (tripId, event, data) => {
@@ -127,5 +271,11 @@ export function createPluginContext(
warn: (msg, meta) => t.emit('log', { level: 'warn', msg, meta }), warn: (msg, meta) => t.emit('log', { level: 'warn', msg, meta }),
error: (msg, meta) => t.emit('log', { level: 'error', msg, meta }), error: (msg, meta) => t.emit('log', { level: 'error', msg, meta }),
}, },
plugins: {
call: (pluginId, fn, args) => t.rpc('plugins.call', { targetId: pluginId, fn, args, _inv: invocationId }),
},
events: {
emit: (name, payload) => { void t.rpc('events.emit', { event: name, payload }); },
},
}; };
} }
@@ -49,6 +49,10 @@ interface Supervised {
lastRss: number; // last reported resident set size (bytes) lastRss: number; // last reported resident set size (bytes)
routes: PluginRouteInfo[]; routes: PluginRouteInfo[];
jobs: string[]; jobs: string[];
hooks: string[]; // provider hooks the plugin implements (e.g. 'placeDetailProvider')
events: string[]; // core events the plugin subscribes to (names or '*')
exports: string[]; // functions the plugin exposes to dependents (ctx.plugins.call)
subscriptions: Array<{ plugin: string; event: string }>; // other-plugin events it listens to
pending: Map<string, Pending>; // host→child invokes awaiting a response pending: Map<string, Pending>; // host→child invokes awaiting a response
invocations: Map<string, number | undefined>; // reqId -> acting user of that invoke (undefined = no user, e.g. a job) invocations: Map<string, number | undefined>; // reqId -> acting user of that invoke (undefined = no user, e.g. a job)
activation?: { resolve: () => void; reject: (e: Error) => void }; activation?: { resolve: () => void; reject: (e: Error) => void };
@@ -80,6 +84,17 @@ const DEFAULTS: Required<SupervisorTuning> = {
maxRssBytes: (Number(process.env.TREK_PLUGIN_MAX_RSS_MB) || 300) * 1024 * 1024, maxRssBytes: (Number(process.env.TREK_PLUGIN_MAX_RSS_MB) || 300) * 1024 * 1024,
}; };
// A plugin may only act as a provider for a hook it BOTH implements (reported by
// the child at load) AND was granted the matching hook:* permission for. The child
// reports Object.keys(def.hooks) with no knowledge of grants, so the grant check
// must happen host-side here — otherwise the hook:* consent is never enforced.
const HOOK_PERMISSION: Readonly<Record<string, string>> = {
photoProvider: 'hook:photo-provider',
calendarSource: 'hook:calendar-source',
placeDetailProvider: 'hook:place-detail-provider',
warningProvider: 'hook:trip-warning-provider',
};
export class PluginSupervisor { export class PluginSupervisor {
private running = new Map<string, Supervised>(); private running = new Map<string, Supervised>();
private sweep: ReturnType<typeof setInterval> | null = null; private sweep: ReturnType<typeof setInterval> | null = null;
@@ -109,6 +124,10 @@ export class PluginSupervisor {
lastRss: 0, lastRss: 0,
routes: [], routes: [],
jobs: [], jobs: [],
hooks: [],
events: [],
exports: [],
subscriptions: [],
pending: new Map(), pending: new Map(),
invocations: new Map(), invocations: new Map(),
}; };
@@ -164,6 +183,52 @@ export class PluginSupervisor {
return this.running.get(id)?.routes ?? []; return this.running.get(id)?.routes ?? [];
} }
/**
* Ids of ACTIVE plugins that may act as a given provider hook — they implement it
* (reported at load) AND hold the matching hook:* grant the admin consented to.
* An unknown hook, or one with no permission mapping, resolves to nobody.
*/
providersOf(hook: string): string[] {
const perm = HOOK_PERMISSION[hook];
if (!perm) return [];
const out: string[] = [];
for (const [id, sup] of this.running) {
if (sup.status === 'active' && sup.hooks.includes(hook) && sup.granted.has(perm)) out.push(id);
}
return out;
}
/** Callable export names an ACTIVE plugin reported at load (ctx.plugins.call target). */
exportsOf(id: string): string[] {
const sup = this.running.get(id);
return sup && sup.status === 'active' ? sup.exports : [];
}
/** Ids of ACTIVE plugins that subscribed to `event` emitted by `sourceId`. */
subscribersOf(sourceId: string, event: string): string[] {
const out: string[] = [];
for (const [id, sup] of this.running) {
if (sup.status === 'active' && sup.subscriptions.some((s) => s.plugin === sourceId && s.event === event)) out.push(id);
}
return out;
}
/**
* Announce a core event to every plugin that subscribed to it (or to '*') AND holds
* the 'events:subscribe' grant. Fire-and-forget: the invoke is NOT awaited (a core
* broadcast must never block on a plugin) and carries no user (trip reads refused).
* Only the event name + tripId are sent — never the payload.
*/
deliverEvent(tripId: number, event: string): void {
for (const [id, sup] of this.running) {
if (sup.status !== 'active' || !sup.granted.has('events:subscribe')) continue;
if (!sup.events.includes(event) && !sup.events.includes('*')) continue;
this.invoke(id, 'invoke.event', { event, tripId }, { actingUserId: undefined, timeoutMs: 5000 }).catch(() => {
/* a subscriber that errors or times out is ignored — events are best-effort */
});
}
}
/** /**
* Send a host→child request (invoke a route or job) and await its response. * Send a host→child request (invoke a route or job) and await its response.
* `actingUserId` binds the authenticated user of this invocation on the HOST * `actingUserId` binds the authenticated user of this invocation on the HOST
@@ -279,10 +344,22 @@ export class PluginSupervisor {
break; break;
} }
case 'loaded': { case 'loaded': {
// Ignore a duplicate/forged `loaded` after activation — a plugin must
// not be able to re-register its route table once it is live.
if (sup.status === 'active') break;
sup.lastBeat = Date.now(); sup.lastBeat = Date.now();
const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: string[] }; const d = msg.data as {
routes?: PluginRouteInfo[]; jobs?: string[]; hooks?: string[]; events?: string[];
exports?: string[]; subscriptions?: Array<{ plugin: string; event: string }>;
};
sup.routes = d.routes ?? []; sup.routes = d.routes ?? [];
sup.jobs = d.jobs ?? []; sup.jobs = d.jobs ?? [];
sup.hooks = d.hooks ?? [];
sup.events = d.events ?? [];
sup.exports = Array.isArray(d.exports) ? d.exports.filter((e): e is string => typeof e === 'string') : [];
sup.subscriptions = Array.isArray(d.subscriptions)
? d.subscriptions.filter((s): s is { plugin: string; event: string } => !!s && typeof s.plugin === 'string' && typeof s.event === 'string')
: [];
this.clearActivationTimer(sup); this.clearActivationTimer(sup);
this.setStatus(sup, 'active'); this.setStatus(sup, 'active');
sup.activation?.resolve(); sup.activation?.resolve();
@@ -305,7 +382,13 @@ export class PluginSupervisor {
break; break;
} }
default: default:
this.hooks.onEvent?.(sup.id, msg.topic, msg.data); // Strict inbound whitelist: the legitimate child→host `evt` topics all
// have explicit cases above. Anything else is a compromised/buggy child
// (or, historically, code that reached the raw channel) — drop it rather
// than forward it blindly. `onEvent` stays on SupervisorHooks, reserved
// for a future SDK-sanctioned custom-event channel, but is not invoked
// for arbitrary topics.
this.hooks.onLog?.(sup.id, 'warn', `dropped unknown plugin event topic: ${msg.topic}`);
} }
} }
} }
@@ -0,0 +1,58 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { canAccessTrip } from '../../db/database';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginRuntimeService } from './plugin-runtime.service';
/**
* GET /api/trip-warnings/:tripId — validation/warning contributions from plugins
* that implement the `warningProvider` hook (#1429). Additive and fail-safe: the
* caller must be able to access the trip, each provider is called host→plugin with
* a short timeout, and a provider that errors or times out contributes nothing.
*/
type Level = 'info' | 'warning' | 'error';
interface Warning {
pluginId: string;
level: Level;
message: string;
dayId?: number;
placeId?: number;
}
@Controller('api/trip-warnings')
@UseGuards(JwtAuthGuard)
export class TripWarningsController {
constructor(private readonly runtime: PluginRuntimeService) {}
@Get(':tripId')
async get(
@Param('tripId') tripIdRaw: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ warnings: Warning[] }> {
if (!pluginsEnabled()) return { warnings: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !canAccessTrip(tripId, userId)) return { warnings: [] };
const ids = this.runtime.providersOf('warningProvider');
const perProvider = await Promise.all(
ids.map(async (id): Promise<Warning[]> => {
try {
const raw = (await this.runtime.invokeHook(id, 'warningProvider', 'getWarnings', [tripId], userId, 5000)) as unknown;
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
return list.map((w) => ({
pluginId: id,
level: w.level === 'error' || w.level === 'info' ? (w.level as Level) : 'warning',
message: String(w.message ?? ''),
dayId: typeof w.dayId === 'number' ? w.dayId : undefined,
placeId: typeof w.placeId === 'number' ? w.placeId : undefined,
}));
} catch {
return []; // a slow / failing provider contributes nothing
}
}),
);
return { warnings: perProvider.flat().filter((w) => w.message) };
}
}
+2 -2
View File
@@ -73,9 +73,9 @@ export class TripsController {
} }
@Get('cover-images/search') @Get('cover-images/search')
async coverImages(@Query('query') query?: string) { async coverImages(@CurrentUser() user: User, @Query('query') query?: string) {
try { try {
const result = await this.trips.searchCoverImages(query || ''); const result = await this.trips.searchCoverImages(query || '', user.id);
if ('error' in result) { if ('error' in result) {
throw new HttpException({ error: result.error }, result.status); throw new HttpException({ error: result.error }, result.status);
} }
+3 -3
View File
@@ -11,7 +11,7 @@ import { listItems as listTodoItems } from '../../services/todoService';
import { listBudgetItems } from '../../services/budgetService'; import { listBudgetItems } from '../../services/budgetService';
import { listReservations } from '../../services/reservationService'; import { listReservations } from '../../services/reservationService';
import { listFiles } from '../../services/fileService'; import { listFiles } from '../../services/fileService';
import { searchUnsplashPhotos } from '../../services/unsplashService'; import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService';
/** /**
* Thin Nest wrapper around the existing trip service + the per-domain list * Thin Nest wrapper around the existing trip service + the per-domain list
@@ -50,8 +50,8 @@ export class TripsService {
return tripSvc.getTripRaw(tripId); return tripSvc.getTripRaw(tripId);
} }
searchCoverImages(query: string) { searchCoverImages(query: string, userId: number) {
return searchUnsplashPhotos(query, 9); return searchUnsplashPhotos(query, 9, getUnsplashKey(userId));
} }
getOwner(tripId: string) { getOwner(tripId: string) {
+19
View File
@@ -0,0 +1,19 @@
// A tiny, dependency-free relay so the plugin runtime can receive core trip events
// without the websocket module (which pulls in `ws`) importing the plugins layer —
// and so tests that mock `./websocket` don't accidentally strip the sink. websocket
// calls emitPluginEvent for every CORE broadcast; the plugin runtime registers the
// sink in onModuleInit. Name-only + best-effort by design (see PluginSupervisor).
let sink: ((tripId: number, event: string) => void) | null = null;
export function setPluginEventSink(fn: ((tripId: number, event: string) => void) | null): void {
sink = fn;
}
export function emitPluginEvent(tripId: number, event: string): void {
if (!sink) return;
try {
sink(tripId, event);
} catch {
/* a plugin sink must never break a core broadcast */
}
}
+3 -3
View File
@@ -26,7 +26,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
`).all(a.place_id); `).all(a.place_id);
const participants = db.prepare(` const participants = db.prepare(`
SELECT ap.user_id, u.username, u.avatar SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ? WHERE ap.assignment_id = ?
@@ -159,7 +159,7 @@ export function moveAssignment(id: string | number, newDayId: string | number, o
export function getParticipants(assignmentId: string | number) { export function getParticipants(assignmentId: string | number) {
return db.prepare(` return db.prepare(`
SELECT ap.user_id, u.username, u.avatar SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ? WHERE ap.assignment_id = ?
@@ -208,7 +208,7 @@ export function setParticipants(assignmentId: string | number, userIds: number[]
} }
return db.prepare(` return db.prepare(`
SELECT ap.user_id, u.username, u.avatar SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM assignment_participants ap FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ? WHERE ap.assignment_id = ?
+16 -13
View File
@@ -622,34 +622,35 @@ export function updateMapsKey(userId: number, maps_api_key: string | null | unde
export function updateApiKeys( export function updateApiKeys(
userId: number, userId: number,
body: { maps_api_key?: string; openweather_api_key?: string } body: { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string }
) { ) {
const current = db.prepare('SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick<User, 'maps_api_key' | 'openweather_api_key'> | undefined; const current = db.prepare('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?').get(userId) as Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined;
db.prepare( db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run( ).run(
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current!.maps_api_key, body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current!.maps_api_key,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key, body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current!.unsplash_api_key,
userId userId
); );
const updated = db.prepare( const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' 'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined; ).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
return { return {
success: true, success: true,
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) }, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
}; };
} }
export function updateSettings( export function updateSettings(
userId: number, userId: number,
body: { maps_api_key?: string; openweather_api_key?: string; username?: string; email?: string } body: { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string }
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } { ): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
const { maps_api_key, openweather_api_key, username, email } = body; const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
if (username !== undefined) { if (username !== undefined) {
const trimmed = username.trim(); const trimmed = username.trim();
@@ -677,6 +678,7 @@ export function updateSettings(
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
if (unsplash_api_key !== undefined) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); } if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); } if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
@@ -687,26 +689,27 @@ export function updateSettings(
} }
const updated = db.prepare( const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' 'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined; ).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
return { return {
success: true, success: true,
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) }, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
}; };
} }
export function getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } { export function getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } {
const user = db.prepare( const user = db.prepare(
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?' 'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?'
).get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined; ).get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined;
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
return { return {
settings: { settings: {
maps_api_key: decrypt_api_key(user.maps_api_key), maps_api_key: decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key), openweather_api_key: decrypt_api_key(user.openweather_api_key),
unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
}, },
}; };
} }
+99 -23
View File
@@ -1,6 +1,7 @@
import { db } from '../db/database'; import { db } from '../db/database';
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types'; import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
import { avatarUrl } from './avatarUrl'; import { avatarUrl } from './avatarUrl';
import { getRates } from './exchangeRateService';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -11,7 +12,7 @@ export { verifyTripAccess } from './tripAccess';
function loadItemMembers(itemId: number | string) { function loadItemMembers(itemId: number | string) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT bm.user_id, bm.paid, bm.amount, u.username, u.avatar SELECT bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_members bm FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ? WHERE bm.budget_item_id = ?
@@ -21,7 +22,7 @@ function loadItemMembers(itemId: number | string) {
function loadItemPayers(itemId: number | string) { function loadItemPayers(itemId: number | string) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT bp.user_id, bp.amount, u.username, u.avatar SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_payers bp FROM budget_item_payers bp
JOIN users u ON bp.user_id = u.id JOIN users u ON bp.user_id = u.id
WHERE bp.budget_item_id = ? WHERE bp.budget_item_id = ?
@@ -60,7 +61,7 @@ export function listBudgetItems(tripId: string | number) {
if (itemIds.length > 0) { if (itemIds.length > 0) {
const allMembers = db.prepare(` const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, u.username, u.avatar SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_members bm FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')}) WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
@@ -77,7 +78,7 @@ export function listBudgetItems(tripId: string | number) {
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {}; const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) { if (itemIds.length > 0) {
const allPayers = db.prepare(` const allPayers = db.prepare(`
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_payers bp FROM budget_item_payers bp
JOIN users u ON bp.user_id = u.id JOIN users u ON bp.user_id = u.id
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')}) WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
@@ -98,6 +99,48 @@ export function listBudgetItems(tripId: string | number) {
return items; return items;
} }
/**
* Freeze the live FX rate at entry time into `exchange_rate` so a settled position
* isn't re-opened when live rates drift later (#1335 / #1445). The stored rate is
* "units of the item/display currency per 1 trip currency" — the settlement
* converts with it via `amount / rate`.
*
* Only freezes for a foreign currency with no explicit rate; degrades to live
* rates if the fetch fails. On update it (re)freezes only when the currency
* changes (checked against `budget_items`), so an unrelated edit never moves
* money. Callers must invoke this *before* the (synchronous) DB write — the raw
* create/update stay sync because better-sqlite3 transactions can't await.
*/
export async function freezeForeignRate(
tripId: string | number,
data: { currency?: string | null; exchange_rate?: number },
existingItemId?: string | number,
existingCurrency?: string | null,
): Promise<void> {
if (data.exchange_rate != null) return; // an explicit rate from the caller wins
const cur = (data.currency || '').toUpperCase();
if (!cur) return; // currency not being set in this request
// Skip the re-freeze when the currency isn't actually changing, so an unrelated
// edit never moves money. Items resolve the prior currency from budget_items; a
// settlement lives in a different table, so its caller passes it in directly.
let prior: string | undefined;
if (existingCurrency !== undefined) {
prior = (existingCurrency || '').toUpperCase();
} else if (existingItemId != null) {
const existing = db.prepare('SELECT currency FROM budget_items WHERE id = ?')
.get(existingItemId) as { currency?: string } | undefined;
if (existing) prior = (existing.currency || '').toUpperCase();
}
if (prior !== undefined && prior === cur) return; // currency unchanged
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
.get(tripId) as { currency?: string } | undefined;
const tripCur = (trip?.currency || 'EUR').toUpperCase();
if (cur === tripCur) return; // same as the trip currency → no conversion to freeze
const rates = await getRates(tripCur);
const r = rates?.[cur];
if (r && r > 0) data.exchange_rate = r;
}
export function createBudgetItem( export function createBudgetItem(
tripId: string | number, tripId: string | number,
data: { data: {
@@ -318,7 +361,7 @@ export function toggleMemberPaid(id: string | number, tripId: string | number, u
.run(paid ? 1 : 0, id, userId); .run(paid ? 1 : 0, id, userId);
const member = db.prepare(` const member = db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar SELECT bm.user_id, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ? AND bm.user_id = ? WHERE bm.budget_item_id = ? AND bm.user_id = ?
`).get(id, userId) as BudgetItemMember | undefined; `).get(id, userId) as BudgetItemMember | undefined;
@@ -332,7 +375,7 @@ export function toggleMemberPaid(id: string | number, tripId: string | number, u
export function getPerPersonSummary(tripId: string | number) { export function getPerPersonSummary(tripId: string | number) {
const summary = db.prepare(` const summary = db.prepare(`
SELECT bm.user_id, u.username, u.avatar, SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar,
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned, SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid, SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
COUNT(bi.id) as items_count COUNT(bi.id) as items_count
@@ -398,20 +441,37 @@ export function calculateSettlement(
// trip-currency → display currency, applied once to the final netted totals. // trip-currency → display currency, applied once to the final netted totals.
const toDisplay = (v: number): number => const toDisplay = (v: number): number =>
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v); base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v / rates[tripCurrency] : v);
// A recorded settle-up amount is entered in whatever display currency the payer was // A recorded settle-up amount is entered in whatever display currency the payer
// viewing (the table has no currency column), so bring it into trip currency to net. // was viewing. New rows capture that currency and the rate frozen at settle time
const settleToTrip = (v: number): number => // (#1445), so a settled position stays balanced when live rates drift — mirroring
base === tripCurrency ? v : (rates && rates[tripCurrency] > 0 ? v * rates[tripCurrency] : v); // toTrip for expenses. Legacy rows (currency = NULL) have no frozen rate, so fall
// back to the old behaviour: assume they were entered in the current display base
// and convert with live rates.
const settleToTrip = (amount: number, sCurrency?: string | null, sRate?: number | null): number => {
if (sCurrency) {
const cur = sCurrency.toUpperCase();
if (cur === tripCurrency) return amount;
if (sRate != null && sRate > 0 && sRate !== 1) return amount / sRate;
// Frozen currency but no usable rate (fetch failed at settle time): live fallback.
if (rates) {
const rCur = rates[cur];
const rTrip = rates[tripCurrency];
if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip;
}
return amount;
}
return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount);
};
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[]; const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(` const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar SELECT bm.budget_item_id, bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_members bm FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?) WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[]; `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
const allPayers = db.prepare(` const allPayers = db.prepare(`
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar SELECT bp.budget_item_id, bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM budget_item_payers bp FROM budget_item_payers bp
JOIN users u ON bp.user_id = u.id JOIN users u ON bp.user_id = u.id
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?) WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
@@ -457,8 +517,9 @@ export function calculateSettlement(
return balances[id]; return balances[id];
}; };
for (const s of settlements) { for (const s of settlements) {
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += settleToTrip(s.amount); const inTrip = settleToTrip(s.amount, s.currency, s.exchange_rate);
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= settleToTrip(s.amount); ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += inTrip;
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= inTrip;
} }
// Calculate optimized payment flows (greedy algorithm) // Calculate optimized payment flows (greedy algorithm)
@@ -501,7 +562,7 @@ export function calculateSettlement(
export function listSettlements(tripId: string | number) { export function listSettlements(tripId: string | number) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id, SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.created_by_user_id,
fu.username AS from_username, fu.avatar AS from_avatar, fu.username AS from_username, fu.avatar AS from_avatar,
tu.username AS to_username, tu.avatar AS to_avatar tu.username AS to_username, tu.avatar AS to_avatar
FROM budget_settlements s FROM budget_settlements s
@@ -513,7 +574,8 @@ export function listSettlements(tripId: string | number) {
return rows.map(r => ({ return rows.map(r => ({
id: r.id, trip_id: r.trip_id, id: r.id, trip_id: r.trip_id,
from_user_id: r.from_user_id, to_user_id: r.to_user_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id,
amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id, amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1,
created_at: r.created_at, created_by_user_id: r.created_by_user_id,
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }), from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }), to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
})); }));
@@ -521,25 +583,39 @@ export function listSettlements(tripId: string | number) {
export function createSettlement( export function createSettlement(
tripId: string | number, tripId: string | number,
data: { from_user_id: number; to_user_id: number; amount: number }, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
createdByUserId?: number, createdByUserId?: number,
) { ) {
const result = db.prepare( const result = db.prepare(
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null); ).run(
tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
data.currency ? data.currency.toUpperCase() : null,
data.exchange_rate != null ? data.exchange_rate : 1,
createdByUserId ?? null,
);
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null; return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
} }
export function updateSettlement( export function updateSettlement(
id: string | number, id: string | number,
tripId: string | number, tripId: string | number,
data: { from_user_id: number; to_user_id: number; amount: number }, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number },
) { ) {
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId); const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!row) return null; if (!row) return null;
db.prepare( db.prepare(`
'UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?' UPDATE budget_settlements SET
).run(data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, id); from_user_id = ?, to_user_id = ?, amount = ?,
currency = CASE WHEN ? THEN ? ELSE currency END,
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END
WHERE id = ?
`).run(
data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null,
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
id,
);
return listSettlements(tripId).find(s => s.id === Number(id)) || null; return listSettlements(tripId).find(s => s.id === Number(id)) || null;
} }
+2 -2
View File
@@ -12,7 +12,7 @@ import { TripFile } from '../types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass,md,markdown'; export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass,pkpasses,md,markdown';
// Video support (#823). Gallery/media uploads accept these in addition to images, // Video support (#823). Gallery/media uploads accept these in addition to images,
// independent of the admin doc-types allowlist. Videos are stored as-is and // independent of the admin doc-types allowlist. Videos are stored as-is and
@@ -164,7 +164,7 @@ export function listFiles(tripId: string | number, showTrash: boolean) {
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[]; const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
const fileIds = files.map(f => f.id); const fileIds = files.map(f => f.id);
let linksMap: Record<number, FileLink[]> = {}; const linksMap: Record<number, FileLink[]> = {};
if (fileIds.length > 0) { if (fileIds.length > 0) {
const placeholders = fileIds.map(() => '?').join(','); const placeholders = fileIds.map(() => '?').join(',');
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as FileLink[]; const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as FileLink[];
+1 -1
View File
@@ -212,7 +212,7 @@ export async function sendPasswordResetEmail(
// No SMTP configured — log the link in a visually distinct block so // No SMTP configured — log the link in a visually distinct block so
// the admin can relay it. Never log the associated user id/email // the admin can relay it. Never log the associated user id/email
// content at a lower level, only what's needed. // content at a lower level, only what's needed.
// eslint-disable-next-line no-console
console.log( console.log(
`\n===== PASSWORD RESET LINK =====\n` + `\n===== PASSWORD RESET LINK =====\n` +
`to: ${to}\n` + `to: ${to}\n` +
+7 -7
View File
@@ -22,7 +22,7 @@ function enrichItems(items: any[]): any[] {
const ownerName = new Map(owners.map(o => [o.id, o.username])); const ownerName = new Map(owners.map(o => [o.id, o.username]));
const recipientRows = db.prepare(` const recipientRows = db.prepare(`
SELECT r.item_id, r.user_id, u.username SELECT r.item_id, r.user_id, COALESCE(u.display_name, u.username) AS username
FROM packing_item_recipients r JOIN users u ON u.id = r.user_id FROM packing_item_recipients r JOIN users u ON u.id = r.user_id
WHERE r.item_id IN (${placeholders}) WHERE r.item_id IN (${placeholders})
`).all(...ids) as { item_id: number; user_id: number; username: string }[]; `).all(...ids) as { item_id: number; user_id: number; username: string }[];
@@ -33,7 +33,7 @@ function enrichItems(items: any[]): any[] {
} }
const contributorRows = db.prepare(` const contributorRows = db.prepare(`
SELECT c.item_id, c.user_id, c.status, u.username SELECT c.item_id, c.user_id, c.status, COALESCE(u.display_name, u.username) AS username
FROM packing_item_contributors c JOIN users u ON u.id = c.user_id FROM packing_item_contributors c JOIN users u ON u.id = c.user_id
WHERE c.item_id IN (${placeholders}) WHERE c.item_id IN (${placeholders})
`).all(...ids) as { item_id: number; user_id: number; status: string; username: string }[]; `).all(...ids) as { item_id: number; user_id: number; status: string; username: string }[];
@@ -290,7 +290,7 @@ export function bulkImport(tripId: string | number, items: ImportItem[], ownerId
export function listBags(tripId: string | number) { export function listBags(tripId: string | number) {
const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId) as any[]; const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId) as any[];
const members = db.prepare(` const members = db.prepare(`
SELECT bm.bag_id, bm.user_id, u.username, u.avatar SELECT bm.bag_id, bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_bag_members bm FROM packing_bag_members bm
JOIN users u ON bm.user_id = u.id JOIN users u ON bm.user_id = u.id
JOIN packing_bags b ON bm.bag_id = b.id JOIN packing_bags b ON bm.bag_id = b.id
@@ -314,7 +314,7 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const uid of userIds) ins.run(bagId, uid); for (const uid of userIds) ins.run(bagId, uid);
const rows = db.prepare(` const rows = db.prepare(`
SELECT bm.user_id, u.username, u.avatar SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.bag_id = ? WHERE bm.bag_id = ?
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[]; `).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
@@ -351,7 +351,7 @@ export function updateBag(
data.user_id ?? null, data.user_id ?? null,
bagId bagId
); );
return db.prepare('SELECT b.*, u.username as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?').get(bagId); return db.prepare('SELECT b.*, COALESCE(u.display_name, u.username) as assigned_username FROM packing_bags b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = ?').get(bagId);
} }
export function deleteBag(tripId: string | number, bagId: string | number) { export function deleteBag(tripId: string | number, bagId: string | number) {
@@ -440,7 +440,7 @@ export function saveAsTemplate(tripId: string | number, userId: number, template
export function getCategoryAssignees(tripId: string | number) { export function getCategoryAssignees(tripId: string | number) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT pca.category_name, pca.user_id, u.username, u.avatar SELECT pca.category_name, pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_category_assignees pca FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? WHERE pca.trip_id = ?
@@ -465,7 +465,7 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
} }
const updated = db.prepare(` const updated = db.prepare(`
SELECT pca.user_id, u.username, u.avatar SELECT pca.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
FROM packing_category_assignees pca FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? AND pca.category_name = ? WHERE pca.trip_id = ? AND pca.category_name = ?
+3 -3
View File
@@ -15,7 +15,7 @@ import {
} from './kmlImport'; } from './kmlImport';
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment'; import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
import * as placePhotoCache from './placePhotoCache'; import * as placePhotoCache from './placePhotoCache';
import { searchUnsplashPhotos } from './unsplashService'; import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService';
import { type UpdateConflict, isUpdateConflict } from './conflictResult'; import { type UpdateConflict, isUpdateConflict } from './conflictResult';
// Reclaim a deleted place's cached marker photo if nothing else references it. // Reclaim a deleted place's cached marker photo if nothing else references it.
@@ -977,9 +977,9 @@ export async function importNaverList(
// Search place image (Unsplash) // Search place image (Unsplash)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function searchPlaceImage(tripId: string, placeId: string, _userId: number) { export async function searchPlaceImage(tripId: string, placeId: string, userId: number) {
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined; const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
if (!place) return { error: 'Place not found', status: 404 }; if (!place) return { error: 'Place not found', status: 404 };
return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5); return searchUnsplashPhotos(place.name + (place.address ? ' ' + place.address : ''), 5, getUnsplashKey(userId));
} }
+13 -21
View File
@@ -352,10 +352,10 @@ export function listMembers(tripId: string | number, tripOwnerId: number) {
// u.is_guest rides along (#1362) so guests stay assignable everywhere a member is, // u.is_guest rides along (#1362) so guests stay assignable everywhere a member is,
// while the UI can badge them and suppress owner-only actions. The owner is never a guest. // while the UI can badge them and suppress owner-only actions. The owner is never a guest.
const members = db.prepare(` const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar, u.is_guest, SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, u.avatar, u.is_guest,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role, CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
m.added_at, m.added_at,
ib.username as invited_by_username COALESCE(ib.display_name, ib.username) as invited_by_username
FROM trip_members m FROM trip_members m
JOIN users u ON u.id = m.user_id JOIN users u ON u.id = m.user_id
LEFT JOIN users ib ON ib.id = m.invited_by LEFT JOIN users ib ON ib.id = m.invited_by
@@ -473,37 +473,28 @@ export interface GuestMember {
/** username is UNIQUE across all users — keep the typed name but disambiguate guests /** username is UNIQUE across all users — keep the typed name but disambiguate guests
* that happen to share it (e.g. two "Anna"s) with a numeric suffix. */ * that happen to share it (e.g. two "Anna"s) with a numeric suffix. */
function uniqueGuestUsername(name: string, excludeId?: number): string {
let candidate = name;
let n = 2;
const probe = excludeId != null
? db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?')
: db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)');
while (excludeId != null ? probe.get(candidate, excludeId) : probe.get(candidate)) {
candidate = `${name} ${n++}`;
}
return candidate;
}
export function createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } { export function createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } {
const display = (name || '').trim(); const display = (name || '').trim();
if (!display) throw new ValidationError('Guest name is required'); if (!display) throw new ValidationError('Guest name is required');
if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer');
// The human name lives in display_name (not unique — two trips can each have a
// "Jake", #1446); username is a uuid handle only for the UNIQUE constraint and is
// never shown (member views COALESCE display_name over it).
const email = `guest-${randomUUID()}@guests.invalid`; const email = `guest-${randomUUID()}@guests.invalid`;
const username = uniqueGuestUsername(display); const username = `guest-${randomUUID()}`;
const create = db.transaction(() => { const create = db.transaction(() => {
const res = db.prepare( const res = db.prepare(
"INSERT INTO users (username, email, password_hash, role, is_guest) VALUES (?, ?, '', 'user', 1)" "INSERT INTO users (username, email, password_hash, role, is_guest, display_name) VALUES (?, ?, '', 'user', 1, ?)"
).run(username, email); ).run(username, email, display);
const guestId = Number(res.lastInsertRowid); const guestId = Number(res.lastInsertRowid);
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId); db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId);
return guestId; return guestId;
}); });
const guestId = create(); const guestId = create();
return { member: { id: guestId, username, email, role: 'member', is_guest: true, avatar_url: null } }; return { member: { id: guestId, username: display, email, role: 'member', is_guest: true, avatar_url: null } };
} }
/** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */ /** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */
@@ -519,8 +510,9 @@ export function renameGuest(tripId: string | number, guestUserId: number, name:
if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer');
if (!guestOfTrip(tripId, guestUserId)) return false; if (!guestOfTrip(tripId, guestUserId)) return false;
const username = uniqueGuestUsername(display, guestUserId); // Rename only the display name — no global-uniqueness dedup, so a rename to a name
db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(username, guestUserId); // another trip's guest already uses no longer produces "Name 2" (#1446).
db.prepare('UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(display, guestUserId);
return true; return true;
} }
@@ -665,7 +657,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
if (notes.length > 0) { if (notes.length > 0) {
if (desc) desc += '\n\n'; if (desc) desc += '\n\n';
desc += 'Notes:\n' + notes.map(n => { desc += 'Notes:\n' + notes.map(n => {
let line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`; const line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`;
return line; return line;
}).join('\n'); }).join('\n');
} }
+41 -12
View File
@@ -2,6 +2,8 @@ import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { safeFetch } from '../utils/ssrfGuard'; import { safeFetch } from '../utils/ssrfGuard';
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
interface UnsplashSearchResponse { interface UnsplashSearchResponse {
results?: { results?: {
@@ -25,7 +27,24 @@ export interface UnsplashPhoto {
link: string | null; link: string | null;
} }
export async function searchUnsplashPhotos(query: string, perPage = 9) { /**
* Resolve an Unsplash access key for a request, in precedence order:
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
* in which case the search falls back to the unauthenticated endpoint.
*/
export function getUnsplashKey(userId: number): string | null {
const env_key = process.env.UNSPLASH_ACCESS_KEY?.trim();
if (env_key) return env_key;
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined;
const user_key = decrypt_api_key(user?.unsplash_api_key);
if (user_key) return user_key;
const admin = db.prepare("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1").get() as { unsplash_api_key: string } | undefined;
return decrypt_api_key(admin?.unsplash_api_key) || null;
}
export async function searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
const trimmed = query.trim(); const trimmed = query.trim();
if (!trimmed) { if (!trimmed) {
return { error: 'Search query is required', status: 400 }; return { error: 'Search query is required', status: 400 };
@@ -36,17 +55,27 @@ export async function searchUnsplashPhotos(query: string, perPage = 9) {
query: trimmed, query: trimmed,
per_page: String(perPage), per_page: String(perPage),
}); });
const response = await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, { // With an access key, use Unsplash's official, authenticated API — datacenter
headers: { // (VPS) IPs get blocked on the unauthenticated web endpoint (#1449). Without
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0', // one, fall back to the web endpoint so zero-config search still works.
Accept: '*/*', const response = accessKey
'Accept-Language': 'en-US', ? await fetch(`https://api.unsplash.com/search/photos?${params.toString()}`, {
Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`, headers: {
'client-geo-region': 'global', Authorization: `Client-ID ${accessKey}`,
'Sec-Fetch-Dest': 'empty', 'Accept-Version': 'v1',
'Sec-Fetch-Site': 'same-origin', },
}, })
}); : await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
Accept: '*/*',
'Accept-Language': 'en-US',
Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`,
'client-geo-region': 'global',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Site': 'same-origin',
},
});
let data: UnsplashSearchResponse; let data: UnsplashSearchResponse;
try { try {
data = await response.json() as UnsplashSearchResponse; data = await response.json() as UnsplashSearchResponse;

Some files were not shown because too many files have changed in this diff Show More