/** * trek-plugin-sdk — the author-facing SDK for building TREK plugins (#plugins, M6). * * Types + `definePlugin` mirror what the isolated runtime injects, so a plugin * written against this package runs unchanged inside TREK. Pure and * dependency-free. */ /** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */ export const PLUGIN_API_VERSION = 1 as const; // Core entity shapes returned by ctx reads/writes. Only `id` is guaranteed; the rest // are the fields plugins most commonly use (typed for autocomplete), left optional // because they mirror raw DB rows — and every shape keeps an index signature, so no // column is ever hidden from you. export interface Trip { id: number; user_id?: number; title?: string; start_date?: string | null; end_date?: string | null; currency?: string | null; [k: string]: unknown } export interface Place { id: number; trip_id?: number; name?: string; lat?: number | null; lng?: number | null; day_id?: number | null; category_id?: number | null; notes?: string | null; [k: string]: unknown } export interface Day { id: number; trip_id?: number; date?: string | null; title?: string | null; [k: string]: unknown } export interface Reservation { id: number; trip_id?: number; type?: string; [k: string]: unknown } export interface PackingItem { id: number; trip_id?: number; name?: string; [k: string]: unknown } export interface TripFile { id: number; trip_id?: number; filename?: string; [k: string]: unknown } export interface BudgetItem { id: number; trip_id?: number; name?: string; total_price?: number | null; currency?: string | null; [k: string]: unknown } export interface Assignment { id: number; day_id?: number; place_id?: number; notes?: string | null; [k: string]: unknown } export interface User { id: number; username?: string; display_name?: string | null; avatar?: string | null; [k: string]: unknown } /** Every ctx.* call is rate-limited per plugin at the host RPC dispatch boundary: * burst 60, sustained 20/s, 16 in-flight. A throttled call is refused (retryable) * rather than executed, with: * HOST_ERROR: rate limit exceeded — slow down ctx.* calls * A legitimate plugin never hits the generous burst. See README § Runtime limits. */ export interface PluginContext { readonly id: string; /** Your `scope:'instance'` settings as the admin saved them, secrets decrypted, frozen * at activation (a save re-spawns you). A field nobody set resolves to its manifest * `default` — only a field with neither is absent. */ readonly config: Readonly>; /** The ACTING USER's own value for one of this plugin's `scope:'user'` settings fields * (decrypted host-side), or the field's manifest `default` when they never set it. * Undefined for a field with neither, and in a userless context (job/onLoad) — fall * back to `config` (the admin-owned instance settings) there. */ settings: { get(key: string): Promise; }; /** Your OWN sqlite database (`db:own`) — a separate file the plugin never gets a * path or connection to directly. Quota: 256 MB per plugin (writes past it fail); * result sets are capped at 100,000 rows. See `tx()` below for the atomic-batch cap. */ db: { query(sql: string, ...args: unknown[]): Promise; exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>; migrate(id: string, sql: string): Promise<{ applied: boolean }>; /** Run several statements atomically on your OWN db — all commit or all roll * back. Each op is one statement; reads see the batch's own earlier writes. * A read returns `{ rows }`, a write `{ changes }`. Max 100 statements. */ tx(ops: Array<{ sql: string; args?: unknown[] }>): Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>; }; trips: { // `asUserId` is accepted for source compatibility but IGNORED by the host — // trip reads are always membership-checked against the authenticated user of // the current invocation (the request's `req.user`), which the plugin cannot // override. Only reachable from a route handler (a user context); a job has // no user and its trip reads are refused. getById(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise; getPlaces(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise; /** Hydrated like the REST list: each row carries `endpoints`, `day_positions` + the day/place joins. */ getReservations(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise; /** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs `db:read:trips`. */ getDays(tripId: number): Promise; /** The trip's lodging blocks (day_accommodations) with joined place fields. Needs `db:read:trips`. */ getAccommodations(tripId: number): Promise; /** Every trip the acting user owns or is a member of (for dashboards/aggregates). Needs `db:read:trips`. */ listMine(): Promise; /** Update trip fields; needs `db:write:trips` + the acting user's trip_edit permission. Route context only. */ update(tripId: number, input: Record): Promise; /** Create a new trip owned by the acting user (importers). `title` required. Needs `db:create:trips` + the acting user's trip_create. */ create(input: { title: string; description?: string; start_date?: string; end_date?: string; currency?: string; reminder_days?: number; day_count?: number }): Promise; /** The trip's member roster (id + display fields only). Membership-checked. Needs `db:read:trips`. */ members(tripId: number): Promise; /** Add a user to a trip (GRANTS ACCESS — its own permission). Needs `db:write:members` + the acting user's member_manage. */ addMember(tripId: number, userId: number): Promise<{ joined: boolean; tripId: number }>; /** Remove a member from a trip (not the owner). Needs `db:write:members` + member_manage. */ removeMember(tripId: number, userId: number): Promise<{ removed: boolean }>; }; // Reservations (bookings). `listMine` reads across every accessible trip (needs // `db:read:trips`); create/update/delete need `db:write:reservations` + the acting // user's reservation_edit permission, and reuse the app's accommodation/budget/ // notification side effects 1:1. reservations: { /** Every reservation across the acting user's accessible trips. Needs `db:read:trips`. */ listMine(): Promise; /** Create a booking on a trip. An `endpoints` array (from/to/stop legs for flights, * trains, ferries) is persisted with it. */ create(tripId: number, input: Record): Promise; /** Update a booking. `endpoints` semantics: omitted = keep, [] = delete all, * array = replace (endpoint ids are NOT stable). */ update(tripId: number, reservationId: number, input: Record): Promise; delete(tripId: number, reservationId: number): Promise<{ deleted: boolean }>; }; // Lodging blocks (day_accommodations): a hotel span from a start day to an end day. // Reads ride on `db:read:trips` (ctx.trips.getAccommodations); writes need // `db:write:accommodations` + the acting user's day_edit permission (like the REST // path — accommodations live in the day service, not the bookings one). Creating one // auto-creates its partner hotel reservation, exactly like the app. accommodations: { create(tripId: number, input: { place_id: number; start_day_id: number; end_day_id: number; check_in?: string | null; check_in_end?: string | null; check_out?: string | null; confirmation?: string | null; notes?: string | null }): Promise; update(tripId: number, accommodationId: number, input: Record): Promise; delete(tripId: number, accommodationId: number): Promise<{ deleted: boolean }>; }; packing: { /** A trip's packing items (hydrated bags/assignees). Needs `db:read:packing`. */ list(tripId: number): Promise; /** Add a packing item (owner = acting user). Needs `db:write:packing` + packing_edit. */ create(tripId: number, input: { name: string; category?: string; checked?: boolean; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean; visibility?: 'common' | 'personal' | 'shared'; recipient_ids?: number[] }): Promise; /** Update a packing item. Needs `db:write:packing` + packing_edit. */ update(tripId: number, itemId: number, input: Record): Promise; /** Delete a packing item. Needs `db:write:packing` + packing_edit. */ delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>; // List/create/update/delete packing bags + set members (no privacy). Needs // `db:write:packing` + packing_edit. /** Needs 'db:write:packing' (intentional — bags are the write-side structure; packing.list is the read surface). */ listBags(tripId: number): Promise; createBag(tripId: number, input: { name: string; color?: string; weight_limit_grams?: number }): Promise; updateBag(tripId: number, bagId: number, input: Record): Promise; deleteBag(tripId: number, bagId: number): Promise<{ deleted: boolean }>; setBagMembers(tripId: number, bagId: number, userIds: number[]): Promise; }; files: { /** A trip's files, trash excluded. Needs `db:read:files`. */ list(tripId: number): Promise; /** A file's bytes as base64 (10MB cap; trashed files refused). Needs `db:read:files:content`. */ getContent(tripId: number, fileId: number): Promise<{ name: string; mimetype: string; size: number; content_base64: string }>; /** Store base64 content as a trip file (10MB cap, blocked extensions refused). Needs `db:write:files` + file_upload. */ create(tripId: number, input: { name: string; content_base64: string; mimetype?: string; description?: string; place_id?: number; reservation_id?: number }): Promise; /** Link an existing file to a same-trip reservation/place/assignment. Needs `db:write:files` + file_edit. */ createLink(tripId: number, fileId: number, opts: { reservation_id?: number; assignment_id?: number; place_id?: number }): Promise; /** Update a file's description/links. Needs `db:write:files` + file_edit. */ update(tripId: number, fileId: number, input: { description?: string; place_id?: number | null; reservation_id?: number | null }): Promise; /** Move a file to the trash. Needs `db:write:files` + file_delete. */ softDelete(tripId: number, fileId: number): Promise<{ deleted: boolean }>; }; /** Collab content (notes/polls/chat). Reads need `db:read:collab`; writes need `db:write:collab` + the acting user's collab_edit. Both need the Collab addon. */ collab: { /** A trip's collab notes (hydrated with author + attachments). Needs `db:read:collab`. */ listNotes(tripId: number): Promise; /** A trip's polls (with options + voters). Needs `db:read:collab`. */ listPolls(tripId: number): Promise; /** A trip's chat messages (newest 100, oldest first; `before` = a message id to page back). Needs `db:read:collab`. */ listMessages(tripId: number, before?: number): Promise; createNote(tripId: number, input: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }): Promise; createPoll(tripId: number, input: { question: string; options: unknown[]; multiple?: boolean; deadline?: string }): Promise; votePoll(tripId: number, pollId: number, optionIndex: number): Promise; createMessage(tripId: number, text: string, replyTo?: number): Promise; }; /** Host-mediated notification. The plugin supplies only target + plain text; the host * owns delivery + preferences. Recipient is FORCED to the acting user (scope 'user', * targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`. * Daily budget: 100/day per plugin (UTC midnight rollover); past it `send` throws * "daily notification budget exhausted (resets at UTC midnight)". See README § Runtime limits. */ notify: { send(input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise<{ sent: boolean }>; }; /** Host-mediated LLM using the admin/user-configured provider — the plugin never holds a * key. `complete` returns { text }; `extract` returns { results } for your JSON schema. * Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`. * Daily budget: 200/day per plugin (UTC midnight rollover); past it these throw * "daily AI budget exhausted (resets at UTC midnight)". See README § Runtime limits. */ ai: { complete(prompt: string, system?: string): Promise<{ text: string }>; extract(text: string, jsonSchema: object, prompt?: string): Promise<{ results: Record[] }>; }; /** Host-brokered outbound OAuth: a short-lived access token for the ACTING USER of a * third-party service the host connected on their behalf (Settings → Plugins → Connect). * Returns null when the user hasn't connected or in a userless context. The host holds * the refresh token + client secret — you never see them. Needs `oauth:client`. * * The host runs the whole flow (authorize -> callback -> token exchange -> refresh) * with PKCE + a 10-minute state TTL. Provider config is the plugin's admin-owned * INSTANCE settings — declare `scope:'instance'` manifest fields named * `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional), * `oauth_client_id`, `oauth_client_secret` and the admin fills them in. Both * endpoint URLs must be https and may not point at loopback/`.local`/`.internal`/ * private/metadata addresses. The host exposes * `/api/plugin-oauth/:id/status|connect|callback|disconnect` for the connect UI — * plugin code only ever calls `getAccessToken()`. See README § OAuth broker. */ oauth: { getAccessToken(): Promise; }; /** Persistent, userless scheduling — a future callback into your `scheduled` handler * that survives restarts. Same risk class + grant as background jobs: needs `jobs:run`, * runs with NO acting user (trip reads refused; own db + declared egress only). Caps: * up to 100 tasks/plugin, 8 KB payload, recurring interval >= 60 s, <= ~1 year out. * `set` is an upsert by name; `cancel` removes it. */ scheduler: { /** Fire once at an absolute epoch-ms time. */ at(whenMs: number, name: string, payload?: unknown): Promise<{ scheduled: boolean }>; /** Fire once after `ms` from now. */ in(ms: number, name: string, payload?: unknown): Promise<{ scheduled: boolean }>; /** Fire repeatedly every `ms` (first fire after `ms`). */ every(ms: number, name: string, payload?: unknown): Promise<{ scheduled: boolean }>; /** Cancel a scheduled task by name. */ cancel(name: string): Promise<{ cancelled: boolean }>; }; /** Host weather cache by coordinates (+ optional YYYY-MM-DD). Tenant-free. Needs `weather:read`. */ weather: { get(lat: number, lng: number, date?: string): Promise; }; /** Exchange rates (tenant-free, cached upstream). Needs `rates:read`. */ rates: { /** A map of quote → rate relative to `base` (e.g. base 'EUR' → { USD: 1.08, ... }). `null` on an upstream failure. */ get(base: string): Promise | null>; }; /** The global place-category reference list (read-only). Needs `db:read:categories`. */ categories: { list(): Promise; }; /** The acting user's own tags. Needs `db:read:tags` (list) / `db:write:tags` (create/update/delete). */ tags: { list(): Promise; create(input: { name: string; color?: string }): Promise; update(tagId: number, input: { name?: string; color?: string }): Promise; delete(tagId: number): Promise<{ deleted: boolean }>; }; /** A trip's to-dos. Needs `db:read:todos` (list) / `db:write:todos` + packing_edit (create/update/delete). */ todos: { list(tripId: number): Promise; create(tripId: number, input: { name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number }): Promise; update(tripId: number, todoId: number, input: Record): Promise; delete(tripId: number, todoId: number): Promise<{ deleted: boolean }>; }; // The acting user's OWN subsystem data across all their trips (not one trip), each // gated on its addon being enabled — mirrors the addon's own REST/MCP readers. journal: { /** The acting user's journals. Needs `db:read:journal` + the journey addon. */ listMine(): Promise; /** The entries of one of the acting user's journeys (photos/story/checkins), access-checked. Needs `db:read:journal`. */ getEntries(journeyId: number): Promise; /** Create an entry on a journey the acting user can edit. Needs `db:write:journal`. */ createEntry(journeyId: number, input: { entry_date: string; [k: string]: unknown }): Promise; /** Create a journal owned by the acting user (importers bootstrap the journal they fill). Needs `db:write:journal`. */ createJourney(input: { title: string; subtitle?: string; trip_ids?: number[] }): Promise; /** Delete one of the acting user's journals. Needs `db:write:journal`. */ deleteJourney(journeyId: number): Promise<{ deleted: boolean }>; /** Update an entry (owner/contributor-gated). Needs `db:write:journal`. */ updateEntry(entryId: number, input: Record): Promise; /** * Attach a photo to an entry, bytes included. Needs `db:write:journal`. * * For an importer that holds an export archive: it has bytes, not a gallery * photo to point at and not a provider asset. `name` supplies the extension * only, the stored filename is the host's. Images only, no SVG, 10MB decoded, * and the operator's allowed-file-types setting applies. */ addEntryPhoto(entryId: number, input: { name: string; content_base64: string; caption?: string }): Promise; /** Delete an entry (owner/contributor-gated). Needs `db:write:journal`. */ deleteEntry(entryId: number): Promise<{ deleted: boolean }>; }; atlas: { /** The acting user's visited countries + regions. Needs `db:read:atlas` + the atlas addon. */ visited(): Promise<{ countries: unknown[]; regions: unknown[] }>; /** The acting user's bucket-list items. Needs `db:read:atlas`. */ bucketList(): Promise; /** Mark/unmark the ACTING USER's own visited countries/regions + bucket list. Needs `db:write:atlas`. */ markCountry(code: string): Promise; unmarkCountry(code: string): Promise; markRegion(regionCode: string, countryCode: string, regionName?: string): Promise; unmarkRegion(regionCode: string): Promise; createBucketItem(input: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string }): Promise; deleteBucketItem(itemId: number): Promise<{ deleted: boolean }>; }; vacay: { /** The acting user's vacation plan data. Needs `db:read:vacay` + the vacay addon. */ mine(): Promise; /** Toggle the ACTING USER's own PTO day on their active plan. Needs `db:write:vacay`. */ toggleEntry(date: string): Promise<{ action: string }>; /** Toggle a company holiday on the acting user's active plan. Needs `db:write:vacay`. */ toggleCompanyHoliday(date: string, note?: string): Promise<{ action: string }>; }; collections: { /** The acting user's saved-place collections. Needs `db:read:collections` + the collections addon. */ listMine(): Promise; /** One of the acting user's collections by id. Needs `db:read:collections` + the collections addon. */ get(id: number): Promise; /** Collections write (per-collection role enforced by the service). Needs `db:write:collections`. */ create(input: Record): Promise; update(id: number, input: Record): Promise; savePlace(input: Record): Promise; copyToTrip(input: Record): Promise; deletePlace(placeId: number): Promise<{ deleted: boolean }>; }; daynotes: { /** A day's notes on a trip (membership-checked). Needs `db:read:daynotes`. */ list(tripId: number, dayId: number): Promise; /** Add a note to a day. Needs `db:write:daynotes` + the acting user's day_edit. */ create(tripId: number, dayId: number, input: Record): Promise; /** Update a day note. Needs `db:write:daynotes` + day_edit. */ update(tripId: number, dayId: number, noteId: number, input: Record): Promise; /** Delete a day note. Needs `db:write:daynotes` + day_edit. */ delete(tripId: number, dayId: number, noteId: number): Promise<{ deleted: boolean }>; }; // "Costs" = budget items. The acting user is bound by the host to the current // invocation; create/update/delete also need 'budget_edit' and the Costs addon // enabled. costs: { getByTrip(tripId: number): Promise; listMine(): Promise; create(tripId: number, input: Record): Promise; update(tripId: number, itemId: number, input: Record): Promise; delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>; }; // Core planner writes (#1429). Membership-checked against the invocation's user; // each needs the matching write scope + the app's place_edit/day_edit permission. places: { create(tripId: number, input: Record): Promise; update(tripId: number, placeId: number, input: Record): Promise; delete(tripId: number, placeId: number): Promise<{ deleted: boolean }>; }; days: { create(tripId: number, input: Record): Promise; update(tripId: number, dayId: number, input: Record): Promise; delete(tripId: number, dayId: number): Promise<{ deleted: boolean }>; }; itinerary: { assign(tripId: number, dayId: number, placeId: number, notes?: string | null): Promise; unassign(tripId: number, assignmentId: number): Promise<{ deleted: boolean }>; }; // Your OWN namespaced key/value store on a trip/place/day (#1429) — enrich core // entities without forking the schema. Needs `db:meta`; the entity must belong to // a trip the current user can access. Values are JSON-serialisable. Quotas (per // plugin + entity): 64 KB serialized JSON per value, 256 chars per key, 100 keys. // See README § Runtime limits. meta: { get(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise; set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise; list(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number): Promise>; delete(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<{ deleted: boolean }>; }; users: { getById(id: number): Promise }; ws: { broadcastToTrip(tripId: number, event: string, data: Record): Promise; broadcastToUser(userId: number, event: string, data: Record): Promise; }; log: { info(msg: string, meta?: Record): void; warn(msg: string, meta?: Record): void; error(msg: string, meta?: Record): void; }; /** Call a function another plugin exposes. `pluginId` must be a declared, version- * satisfied dependency that lists `fn` in its manifest `capabilities.provides`. The * call runs as the current acting user. */ plugins: { call(pluginId: string, fn: string, args?: unknown): Promise; }; /** Publish an event to dependents that subscribed to it. `name` must be declared in * this plugin's manifest `capabilities.emits`. Fire-and-forget. */ events: { emit(name: string, payload?: unknown): void; }; } export interface PluginRequest { method: string; path: string; query: Record; body: unknown; /** Inbound headers — ONLY populated on `auth:false` routes (webhooks), and only an * explicit, credential-free allowlist (signature + event headers from the common * providers; never Cookie/Authorization/session). Empty on authenticated routes. * Verify a provider signature against a secret you hold in `ctx.config`/`ctx.settings`. */ headers: Record; /** * The RAW request body, base64-encoded — set only on `auth:false` routes (webhooks), * `null` elsewhere. This is what you must run an HMAC over: `body` above is the PARSED * value, and re-serializing it will not reproduce the exact bytes the sender signed * (key order, whitespace and unicode escaping all differ), so the signature won't match. */ rawBodyBase64?: string | null; user: { id: number; username: string; isAdmin: boolean } | null; } export interface PluginResponse { status: number; headers?: Record; body?: unknown; } export interface PluginRoute { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; /** Default true. Set false for OAuth callbacks / webhooks (public route). */ auth?: boolean; handler(req: PluginRequest, ctx: PluginContext): Promise; } export interface PluginJob { id: string; /** Cron expression; the host owns the schedule. */ schedule: string; handler(ctx: PluginContext): Promise; } // ── integration hook interfaces ────────────────────────────────────────────── export interface Photo { id: string; title?: string; thumbnailUrl: string; fullUrl: string; takenAt?: string; } export interface PhotoProvider { /** Search your photo backend. `ctx` is the last arg so you can reach it via * ctx.settings/oauth/http. Needs `hook:photo-provider`. Surfaced in the photo picker * and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https. * The host caps results at 60 photos per page. */ search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>; getById(id: string, ctx: PluginContext): Promise; } /** * A notification, already rendered by the host into the recipient's language. * A channel plugin never touches i18n — it only delivers. */ export interface NotificationMessage { /** The TREK event that produced this, e.g. `trip_invite`. */ event: string; title: string; body: string; /** Absolute deep link back into TREK, when the event has one. */ url?: string; tripName?: string; } /** * Deliver TREK notifications over a channel TREK doesn't ship (Gotify, Pushover, …). * Needs `hook:notification-channel`. * * Unlike every other hook, this one is HOST-initiated for an arbitrary recipient, so * it runs WITHOUT an acting user: `ctx.settings.get()` returns undefined here and trip * reads are refused. The recipient's own `scope:'user'` settings are decrypted by the * host and handed to you as `config` instead — that is the only way to reach them, and * it is why a channel plugin cannot enumerate the recipient's trips. * * Declare the channel in your manifest: * "capabilities": { "notificationChannel": { "title": "Gotify" } } */ export interface NotificationChannel { send(msg: NotificationMessage, config: Record, ctx: PluginContext): Promise; /** Backs the "Send test" button in the user's notification settings. */ test?(config: Record, ctx: PluginContext): Promise; } /** * Result of a settings-page action. `message` is shown to the user beside the button * (bounded + emoji-stripped host-side); throwing is equivalent to `{ ok: false }` with * the error message. */ export interface PluginActionResult { ok?: boolean; message?: string; } export interface CalendarEvent { id: string; title: string; start: string; end: string; allDay: boolean; } export interface CalendarSource { /** A short label for this source (shown in the calendar UI). Needs `hook:calendar-source`. */ getName(ctx: PluginContext): string; // 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). `ctx` is the // last arg. Aggregated for the signed-in user at `GET /api/plugin-calendar`. // The host caps results at 500 events per source per request. getEvents(userId: number, start: string, end: string, ctx: PluginContext): Promise; } /** One row of extra place info TREK renders natively (reviews/ratings/links/…). */ export interface PlaceDetailItem { label: string; value?: string; url?: string; } export interface PlaceDetailProvider { /** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. * The host caps results at 12 items per provider. */ getDetails(placeId: number, ctx: PluginContext): Promise; } /** * A place a search provider found, in the shape the host turns into a search row. * * `rating` is the one field open data cannot answer, and the reason this hook exists: * OpenStreetMap carries no ratings at all, so "the best one around here" needs an * index that has them. Zero to five, the scale every such index uses. */ export interface SearchResultPlace { /** Stable id in your own index. The host namespaces it as `plugin::`. */ id?: string; name: string; lat: number; lng: number; address?: string; rating?: number; website?: string; phone?: string; category?: string; description?: string; } /** What the host asks a search provider to look for. */ export interface SearchRequest { query: string; /** The most rows worth returning. The host caps it at 20 whatever it is asked for. */ limit: number; /** The caller's language tag, for indexes that carry localized names. */ lang?: string; /** Where the person is looking, when there is somewhere to bias toward. */ near?: { lat: number; lng: number }; /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ category?: string; bounds?: { south: number; west: number; north: number; east: number }; } export interface SearchProvider { /** Places for a query, drawn into TREK's own search list beside the core results. * Needs `hook:search-provider`. The host caps results at 20 places per provider and * gives you 2 seconds to answer, because somebody is waiting on the list. * * Called for an explicit search, not for every keystroke: an external index has rate * limits, and a request per typed letter would spend them on words nobody finished. */ search(request: SearchRequest, ctx: PluginContext): Promise; /** Optional: places for the query while it is still being typed, shown under the * core suggestions in the place search's dropdown. Implement it only when your index * can take a request per keystroke, such as one you keep locally; leave it out and * your places appear once the search is run, as before. * * Called from the second typed character on, with `limit` 3, and given 800 ms. The * host keeps at most 3 rows across every provider, so return your best few, and * `near` is where the person is planning. Picking a row takes it as it is, so fill in * what you know (address, website, phone) here rather than later. */ suggest?(request: SearchRequest, ctx: PluginContext): Promise; } /** * What the host asks a POI category provider for: the places of ONE of your declared * `capabilities.poiCategories` inside the map area the user is looking at. */ export interface PoiCategoryRequest { /** One of your own `capabilities.poiCategories` ids. The host never sends any other. */ category: string; /** The viewport, narrowed to at most 0.5 degrees a side and folded onto -180..180. */ bounds: { south: number; west: number; north: number; east: number }; /** The user's TREK language (`de`, `zh-TW`...), for indexes with localized names. */ lang?: string; /** The most places worth returning. The host keeps at most 60 whatever you send. */ limit: number; } /** One row only your index knows, shown in the map popup: a trail length, a step-free entrance. */ export interface PoiDetail { /** At most 40 characters. */ label: string; /** At most 120 characters. */ value: string; } /** A place in one of your categories. */ export interface PoiCategoryPlace { /** Stable id in your own index. The host namespaces it as `plugin::`. */ id?: string; name: string; lat: number; lng: number; address?: string; website?: string; phone?: string; /** Zero to five. */ rating?: number; /** At most 6 rows. */ details?: PoiDetail[]; } export interface PoiCategoryProvider { /** Places for a chip you added to the trip map's "Explore places" pill. * Needs `hook:poi-category-provider` and the category declared in * `capabilities.poiCategories`. Runs as the user who picked the chip, so * `ctx.settings.get()` is theirs. The host keeps places inside `bounds` only, at most * 60 of them, and gives you 8 seconds to answer. Called when the user picks the chip * or asks to search the area again, not on every pan. */ getPois(request: PoiCategoryRequest, ctx: PluginContext): Promise; } /** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */ export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; } export interface WarningProvider { /** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. * The host caps results at 20 warnings per provider, each message truncated to 300 chars. */ getWarnings(tripId: number, ctx: PluginContext): Promise; } /** Host-rendered contributions into a native trip-planner view (reservations/places/day), * keyed to an entity by id. DECLARATIVE ONLY — a column is text/badge/link, an action is * a labelled button whose target opens your sandboxed frame or calls a route. Never raw * HTML/markup; the host renders + sanitizes everything. */ export type ContributionTone = 'default' | 'success' | 'warn' | 'danger'; /** An extra read-only cell/badge on an entity's row/card. */ export interface TableColumnContribution { kind: 'column'; entityId: number; // the reservation/place/day id this attaches to id: string; // stable per-contribution id (for React keys / dedupe) label: string; value?: string; url?: string; // http/https/mailto only — the host rejects any other scheme icon?: string; // a lucide icon name, resolved by the host tone?: ContributionTone; } /** A labelled button on an entity's row/card; its target opens your sandboxed frame * (`{kind:'frame', sub}`) or invokes one of your routes (`{kind:'route', method, sub}`). */ export interface TableActionContribution { kind: 'action'; entityId: number; id: string; label: string; icon?: string; target: { kind: 'frame'; sub: string } | { kind: 'route'; method: 'GET' | 'POST'; sub: string }; } export type TableContribution = TableColumnContribution | TableActionContribution; export interface TableContributor { /** `view` is one of 'reservations' | 'places' | 'day' | 'costs' | 'packing' | 'files'. * Runs with the current user bound, on a short timeout; a slow/failing call is * skipped, never fatal. Needs `hook:table-contributor`. The host caps results at * 20 columns and 10 actions per entity. */ getContributions(view: string, tripId: number, ctx: PluginContext): Promise; } /** A bounded marker the host renders onto the trip map (#587). Declarative only — * the host draws a Leaflet marker + popup; plugin JS never runs on the map canvas. */ export interface MapMarkerContribution { id: string; // stable per-marker id (React key / dedupe) lat: number; // -90..90 lng: number; // -180..180 label?: string; // short label shown in the popup title popupText?: string; // one line of body text (host-sanitized, plain text) url?: string; // http/https/mailto only — the host rejects any other scheme icon?: string; // a lucide icon name, resolved by the host tone?: ContributionTone; } export interface MapMarkerProvider { /** Return markers to overlay on a trip's map. Runs with the current user bound, * on a short timeout; the host caps the marker count and skips a failing call. * Needs `hook:map-marker-provider`. The host caps results at 200 markers per provider. */ getMarkers(tripId: number, ctx: PluginContext): Promise; } /** One shape in a map layer. Declarative only — the host draws it; styling is the * tone palette plus clamped numerics (width 1–8, opacity 0.05–1) and a dash enum. */ export interface MapLayerFeature { type: 'polyline' | 'polygon' | 'circle'; points?: Array<[number, number]>; // [lat,lng] pairs — polyline (≥2) / polygon (≥3) center?: [number, number]; // circle center [lat,lng] radiusM?: number; // circle radius in metres (1..2,000,000) tone?: ContributionTone; width?: number; // stroke width, clamped 1..8 (default 3) dash?: 'solid' | 'dash' | 'dot'; // stroke style (default solid) opacity?: number; // stroke opacity, clamped 0.05..1 (default 0.8) fill?: boolean; // polygon/circle: tint the inside (default true) label?: string; // short tooltip text (≤80 chars) } /** A bounded vector overlay the host renders onto the trip map — a computed route, * a reachable-range corridor, a zone. Complements mapMarkerProvider (points). */ export interface MapLayerContribution { id: string; // stable per-layer id (React key / dedupe) name?: string; // short layer name features: MapLayerFeature[]; // host caps: 4 layers + 150 features + 8000 vertices per plugin } export interface MapLayerProvider { /** Return layers to overlay on a trip's map. Runs with the current user bound, * on a short timeout; the host caps the vertex budget and skips a failing call. * Needs `hook:map-layer-provider`. */ getLayers(tripId: number, ctx: PluginContext): Promise; } /** One waypoint of a route request — a located stop of the day being routed. */ export interface RouteWaypoint { lat: number; lng: number; name?: string; // the stop's display name, when known placeId?: number; // the TREK place behind this stop, when it is one } /** What the planner asks a routeProvider to route. */ export interface RouteRequest { tripId: number; dayId: number | null; // the selected day, when the request is day-scoped profile: string; // one of the plugin's declared capabilities.routeProfiles ids waypoints: RouteWaypoint[]; // 2..30 located stops, in visit order } /** One leg of the returned route — between consecutive request waypoints. */ export interface RouteLeg { distance: number; // metres duration: number; // seconds (driving + any stop time you fold into the leg) note?: string; // short text shown on the leg connector (e.g. "25 min charge"), ≤120 chars } /** An intermediate stop on the returned route (a charging stop, a rest area). */ export interface RouteViaPoint { lat: number; lng: number; label?: string; // short marker text, ≤80 chars tone?: ContributionTone; dwellSeconds?: number; // planned time at the stop (0..86400) } /** A computed route. The host validates it whole: coordinates are range-checked * (≤10000 vertices), legs must be exactly waypoints-1 entries, vias are capped * at 40 — a malformed result is discarded and the planner falls back to straight * lines, like an OSRM outage. */ export interface RouteProviderResult { coordinates: Array<[number, number]>; // [lat,lng] polyline of the whole route distance: number; // metres, whole route duration: number; // seconds, whole route legs: RouteLeg[]; viaPoints?: RouteViaPoint[]; } export interface RouteProvider { /** Route the given waypoints under one of the plugin's declared profiles * (`capabilities.routeProfiles`). Runs with the current user bound, on a 20 s * timeout (room for an external solver via declared egress); a failing call * falls back to straight lines. Needs `hook:route-provider`. */ getRoute(request: RouteRequest, ctx: PluginContext): Promise; } /** A time contribution the host renders into the day plan — "35 min charging at * this stop", "45 min security before this flight". Anchored to an assignment or * reservation row (or the start/end of a day); `minutes` also counts into the * day's route-footer total. */ export interface DayScheduleContribution { id: string; // stable per-item id (React key / dedupe) dayId: number; // must be a day of the requested trip assignmentId?: number; // anchor under this itinerary place row… reservationId?: number; // …or under this booking row… position?: 'start' | 'end'; // …or at the start/end of the day (default 'end') minutes?: number; // planned time, 1..1440 — shown and totalled label: string; // short text (≤120 chars) tone?: ContributionTone; } export interface DayScheduleProvider { /** Return schedule contributions for a trip's days. Runs with the current user * bound, on a short timeout; the host caps the item count (≤60/plugin) and * skips a failing call. Needs `hook:day-schedule-provider`. */ getSchedule(tripId: number, ctx: PluginContext): Promise; } /** A colour the host paints into one day card in the Plan sidebar (and into that day's * mobile chip) — so a trip split into legs shows its leg membership while you scroll * the itinerary. * * Pick a `tone` from the shared palette, or send your own `#rrggbb` when four tones * cannot keep your legs apart — a twenty-stop trip needs twenty colours. Either way * you choose the hue and the host chooses the weight: it sets the alpha per theme and * per region and clamps a colour's lightness into a band that reads on both the light * and the dark sidebar. So the tint always lands as a wash behind the day, never as a * fill, and no colour you send can make a day unreadable. Anything that is not exactly * six hex digits is ignored (`#RGB` shorthand and CSS names included). * * The card has three separately tintable regions. Set `tone` / `color` to paint all of * them, or name regions individually for finer control — e.g. a bold badge marking the * leg with the activity list left plain, so a dense day stays easy to read. * * The regions map onto the DESKTOP day card's layout. The mobile day strip is built * differently and honours only the badge region, as the day chip's tint; header and * activity tints do not render on phones. So treat the badge (or the shorthands, which * include it) as the one region every user sees, and the finer regions as desktop * refinement. * * Colour is decoration, not information: pair it with `label` (and with a * dayScheduleProvider row where it matters) so the meaning survives for anyone who * cannot tell your legs apart by hue. */ export interface DayTintContribution { dayId: number; // must be a day of the requested trip /** Shorthands: paint every region you do NOT name below. Omit both and only the * regions you name are tinted. `color` wins over `tone` at the same level. */ tone?: ContributionTone; color?: string; // your own colour, `#rrggbb` only /** The day-number badge — the smallest, boldest mark. Also tints the mobile day chip. */ badgeTone?: ContributionTone; badgeColor?: string; /** The day header row (number, title, date, cost). Its hover state deepens the tint. */ headerTone?: ContributionTone; headerColor?: string; /** The expanded activity list — places, bookings and transport for the day. The * largest surface and the one behind the densest text, so it is tinted faintest. */ activityTone?: ContributionTone; activityColor?: string; label?: string; // optional tooltip on the day (≤60 chars) } export interface DayTintProvider { /** Return one tint per day you want coloured — the natural cap is the trip's day * count, so this scales to a six-month itinerary where a per-day dayScheduleProvider * item would hit the ≤60 limit. Runs with the current user bound, on a short * timeout; a failing call is skipped. * * A day takes at most one contribution — it is resolved whole, per day, never * per region. Within your own list the first entry for a day wins; across plugins * the first granted provider wins. Both are deterministic, so a day never flickers * between two colours, and two plugins can never each own part of one card. * Needs `hook:day-tint-provider`. */ getDayTints(tripId: number, ctx: PluginContext): Promise; } /** A text-only section the host appends to a trip's PDF export. Declarative only — * plain strings the host lays out and escapes; no markup ever reaches the document. */ export interface PdfSection { title: string; // section heading (capped at 120 chars) paragraphs?: string[]; // body text — ≤20 paragraphs of ≤2000 chars each table?: { headers: string[]; rows: string[][] }; // simple table — ≤8 headers, ≤50 rows } export interface PdfSectionProvider { /** Return sections to append to a trip's PDF export. Runs with the current user * bound, on a short timeout; the host caps counts/lengths and skips a failing * call. Needs `hook:pdf-section-provider`. The host caps results at 5 sections * per provider (see `PdfSection` above for the per-section paragraph/table caps). */ getSections(tripId: number, ctx: PluginContext): Promise; } /** One country in an Atlas tint layer. `code` is ISO-3166 alpha-2 (uppercased by the host). */ export interface AtlasLayerCountry { code: string; tone?: ContributionTone; label?: string; } /** A country tint layer the host draws over the Atlas world map (wishlists, advisories, …). */ export interface AtlasLayer { id: string; // stable per-layer id (React key / dedupe) name?: string; // short layer name countries: AtlasLayerCountry[]; // ≤300 countries per layer } export interface AtlasLayerProvider { /** Return tint layers for the ACTING USER's Atlas map. User-scoped — the host binds * the current user; the hook takes no target parameter. Runs on a short timeout; * the host caps the layer/country counts and skips a failing call. * Needs `hook:atlas-layer-provider`. The host caps results at 3 layers per provider * (see `AtlasLayer` above for the per-layer country cap). */ getLayers(ctx: PluginContext): Promise; } /** One badge the host renders on a dashboard trip card. Declarative primitives only — * the host draws the badge; plugin JS never runs on the dashboard. */ export interface TripCardContribution { tripId: number; // which of the passed-in trips this badge belongs to id: string; // stable per-badge id (React key / dedupe) label: string; // short label (e.g. "Visa ✓", "3 tasks") value?: string; // optional secondary text icon?: string; // a lucide icon name, resolved by the host tone?: ContributionTone; url?: string; // http/https/mailto only — the host rejects any other scheme } export interface TripCardProvider { /** Return badges for the dashboard trip cards currently on screen. `tripIds` are * exactly those cards (each already access-checked for the acting user). Runs with * the current user bound, on a short timeout; the host caps the badge count and * skips a failing call. Needs `hook:trip-card-provider`. The host caps results at * 4 badges per trip, 240 total per provider. */ getCards(tripIds: number[], ctx: PluginContext): Promise; } /** One row of extra info TREK renders under a journal entry (same shape as PlaceDetailItem). */ export interface JournalEntryRow { label: string; value?: string; url?: string; } export interface JournalEntryProvider { /** Return rows for a journal entry. Runs with the current user bound, on a short * timeout; the host caps the row count and skips a failing call. * Needs `hook:journal-entry-provider`. */ getRows(entryId: number, ctx: PluginContext): Promise; } /** A core-event subscription (#1429 eco). Handlers run with NO user (like a job). * Needs `events:subscribe`. */ export interface PluginEventSubscription { /** A core event name `:` (e.g. `place:created`, `day:updated`, `file:created`) * where `` is a value in EVENT_FAMILIES, or `*` for all. */ on: string; // `entity` = the event family (e.g. 'reservation'); `entityId` = WHICH entity changed, // when known. `snapshot` = a whitelisted field view of the changed entity, delivered // only when the plugin also holds EVENT_SNAPSHOT_GRANT[family] — never user ids, // private packing items or secrets; delete/reorder/bulk events carry none. Still no // acting user: a trip read from the handler is refused. handler(payload: { event: string; tripId: number; entity?: string; entityId?: number; snapshot?: Record }, ctx: PluginContext): Promise | void; } /** A function this plugin exposes to its dependents (declared in `capabilities.provides`). */ export type PluginExport = (args: unknown, ctx: PluginContext) => Promise | unknown; /** A subscription to another plugin's event. Authorized by declaring that plugin as a * `pluginDependency`; the handler runs with NO user and receives the emitter's payload. */ export interface PluginSubscription { plugin: string; event: string; handler(payload: unknown, ctx: PluginContext): Promise | void; } /** * Publishes MCP tools on TREK's own MCP server, so an assistant can call into * the plugin as the requesting user. Requires the `mcp:tools` permission. * * `tools` lists which of the tools declared in `capabilities.mcpTools` this * build actually implements. The host advertises the intersection of the two: * the manifest is signed and re-consented, this list is not, so a tool the * manifest never declared is ignored rather than trusted. * * `callTool` receives the plugin-local name, without the `plugin__` prefix * the tool is advertised under. Return anything JSON-serialisable; the host * wraps it into an MCP result envelope, and a throw becomes a tool error the * assistant can read and recover from. */ export interface McpToolProvider { tools: string[]; callTool(call: { name: string; args: unknown }, ctx: PluginContext): Promise | unknown; } export interface PluginDefinition { onLoad?(ctx: PluginContext): Promise | void; onUnload?(ctx: PluginContext): Promise | void; routes?: PluginRoute[]; jobs?: PluginJob[]; /** Handles a callback registered via ctx.scheduler (userless, like a job). The * `name` identifies which scheduled task fired; `payload` is what you passed. */ scheduled?(input: { name: string; payload: unknown }, ctx: PluginContext): Promise | void; /** GDPR erasure: a TREK account was deleted — remove everything you hold about it * from your OWN db. Userless (no acting user). Needs `hook:user-data`. The host * calls this durably (queued, retried until it succeeds), so make it idempotent. */ deleteUserData?(input: { userId: number }, ctx: PluginContext): Promise | void; /** GDPR portability: return the data you hold about a user (own db only), as a * JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */ exportUserData?(input: { userId: number }, ctx: PluginContext): Promise | unknown; events?: PluginEventSubscription[]; /** * Buttons on the plugin's settings forms ("Test connection", "Sync now", "Purge * cache"). The key must match an entry in the manifest's `actions`; that entry's * `scope` decides WHERE the button renders — `'user'` (default) on the user Settings * tab, `'instance'` in the admin instance-settings dialog. * * USER-INITIATED either way, so unlike the notificationChannel hook there IS an acting * user — the person who clicked (a user, or an admin for an instance action). * `ctx.settings.get()` returns THEIR value, `ctx.config` is the instance config, and * trip reads are membership-checked against them. */ actions?: Record Promise | PluginActionResult | void>; hooks?: { photoProvider?: PhotoProvider; calendarSource?: CalendarSource; placeDetailProvider?: PlaceDetailProvider; searchProvider?: SearchProvider; poiCategoryProvider?: PoiCategoryProvider; warningProvider?: WarningProvider; tableContributor?: TableContributor; mapMarkerProvider?: MapMarkerProvider; mapLayerProvider?: MapLayerProvider; routeProvider?: RouteProvider; dayScheduleProvider?: DayScheduleProvider; dayTintProvider?: DayTintProvider; pdfSectionProvider?: PdfSectionProvider; atlasLayerProvider?: AtlasLayerProvider; journalEntryProvider?: JournalEntryProvider; tripCardProvider?: TripCardProvider; notificationChannel?: NotificationChannel; mcpToolProvider?: McpToolProvider; }; /** Functions exposed to dependents (names must match manifest `capabilities.provides`). */ exports?: Record; /** Subscriptions to other plugins' events (each `plugin` must be a declared dependency). */ subscriptions?: PluginSubscription[]; } /** Define a plugin. Gives you types; the returned object is what TREK loads. */ export function definePlugin(def: PluginDefinition): PluginDefinition { return def; } export { validateManifest, settingDefaults, SETTING_FIELD_KEYS, CHANNEL_EVENTS, type PluginManifest, type NormalizedManifest, type ValidationResult, type ManifestSettingField, type ManifestAction, type ManifestCapabilities, } from './manifest.js'; export { createMockHost, type MockHostOptions } from './mock-host.js'; // The permissions TREK enforces OUTSIDE ctx — hooks, events, jobs, egress. An entry // point you implement without its grant is never called in production, silently; these // are what `dev` and the mock driver use to make that loud. export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints, } from './permissions.js'; // The core-event catalog: families and the snapshot-delivery permission each family requires. export { EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS, } from './generated/host-facts.js'; // The lucide icons a `capabilities.poiCategories` entry may use, and the per-plugin cap. export { POI_CATEGORY_ICONS, POI_CATEGORY_MAX } from './generated/host-facts.js'; /** Scope for host-managed, per-user session state in a sandboxed plugin UI. */ export type PluginSessionStorageScope = 'plugin' | 'trip'; /** Limits enforced independently for the plugin scope and each trip scope. */ export const PLUGIN_SESSION_MAX_KEYS = 32; export const PLUGIN_SESSION_MAX_KEY_LENGTH = 64; export const PLUGIN_SESSION_MAX_VALUE_BYTES = 1024; export interface PluginSessionStorageOptions { scope?: PluginSessionStorageScope; } /** The `window.trek.session` surface. Values must be JSON-serialisable. */ export interface PluginSessionStorage { get(key: string, options?: PluginSessionStorageOptions): Promise; set(key: string, value: unknown, options?: PluginSessionStorageOptions): Promise; remove(key: string, options?: PluginSessionStorageOptions): Promise; clear(options?: PluginSessionStorageOptions): Promise; } // The design kit for page/widget UIs: inline these into your client/index.html // (or drop a `` marker and let `dev`/`pack` expand it) to get the // native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge. export { TREK_UI_CSS, TREK_THEME_JS, TREK_UI_MARKER, injectTrekUi } from './ui/kit.js';