Files
TREK/plugin-sdk/src/index.ts
T
Maurice 95f9f031c4 feat(plugins): cut entity results down to a published field list
The entity reads of the plugin RPC surface handed plugins whatever their
query produced, mostly SELECT t.*, with credentials stripped by a denylist
(withoutFeedToken). Every column a migration added reached every plugin
holding the read grant, and renaming one was an unversioned break of a
published API.

protocol/output-contract.ts now states, for every wire method, what its
result is: rows of one of the nine entities the SDK types (trip, place,
day, reservation, packingItem, tripFile, budgetItem, assignment, user), a
value the host builds itself, or a read model passed on as the REST route
returns it. The router applies it to each result on the way out, so an
entity row keeps exactly the fields plugins received when the list was
drawn, in the same order and with the same values, minus the trip's
feed_token (listMine, create and update used to carry it as null). The
table is keyed by every method, so a new one does not compile without an
entry.

output-contract.schema.test.ts holds the lists to the migrated schema: a
new column on a whole-row table fails until it is published or withheld,
and each entity method is called on the real services to prove the
contract drops nothing but the withheld fields.

gen:plugin-facts publishes the lists to the SDK as PLUGIN_ENTITY_FIELDS
and PLUGIN_METHOD_RESULT, and the SDK's entity interfaces are type-checked
against them. Place.day_id, which no read ever delivered, is deprecated
rather than removed. KNOWN_ADDONS is generated from ADDON_IDS as well,
which adds the dawarich, roadtrip and tours ids the hand-kept copy lacked.
The SDK goes to 1.9.0, and its README and the plugin wiki pages describe
what a ctx call returns.
2026-10-08 18:41:01 +02:00

1037 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*/
import type { PLUGIN_ENTITY_FIELDS } from './generated/host-facts.js';
/** 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.
// What a row actually carries is the published field list in PLUGIN_ENTITY_FIELDS
// (generated from TREK's output contract): a row holds those fields and no others, so
// a column TREK adds later is not delivered until TREK publishes it there. The index
// signature types the published fields these interfaces do not name.
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;
/** @deprecated Never delivered: a place has no day of its own. Its days are the
* `assignments` of `trips.getDays()`, each carrying `day_id` and `place_id`. */
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 field an entity interface above names has to be one TREK delivers. A field
// that is not in PLUGIN_ENTITY_FIELDS fails this check at compile time, so the
// interfaces cannot promise a field the host drops. `Place.day_id` predates the
// contract and is the one deprecated exception.
type NamedKeys<T> = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] };
type Undelivered<T, E extends keyof typeof PLUGIN_ENTITY_FIELDS> = Exclude<NamedKeys<T>, (typeof PLUGIN_ENTITY_FIELDS)[E][number]>;
type NoneUndelivered<T extends never> = T;
type EntityFieldsAreDelivered = [
NoneUndelivered<Undelivered<Trip, 'trip'>>,
NoneUndelivered<Exclude<Undelivered<Place, 'place'>, 'day_id'>>,
NoneUndelivered<Undelivered<Day, 'day'>>,
NoneUndelivered<Undelivered<Reservation, 'reservation'>>,
NoneUndelivered<Undelivered<PackingItem, 'packingItem'>>,
NoneUndelivered<Undelivered<TripFile, 'tripFile'>>,
NoneUndelivered<Undelivered<BudgetItem, 'budgetItem'>>,
NoneUndelivered<Undelivered<Assignment, 'assignment'>>,
NoneUndelivered<Undelivered<User, 'user'>>,
];
/** 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<Record<string, unknown>>;
/** 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<unknown>;
};
/** 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<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
migrate(id: string, sql: string): Promise<{ applied: boolean }>;
/** Run several statements atomically on your OWN db — all commit or all roll
* back. Each op is one statement; reads see the batch's own earlier writes.
* A read returns `{ rows }`, a write `{ changes }`. Max 100 statements. */
tx(ops: Array<{ sql: string; args?: unknown[] }>): Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>;
};
trips: {
// `asUserId` is accepted for source compatibility but IGNORED by the host —
// 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<Trip | null>;
getPlaces(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Place[]>;
/** 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<Reservation[]>;
/** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs `db:read:trips`. */
getDays(tripId: number): Promise<Day[]>;
/** The trip's lodging blocks (day_accommodations) with joined place fields. Needs `db:read:trips`. */
getAccommodations(tripId: number): Promise<unknown[]>;
/** Every trip the acting user owns or is a member of (for dashboards/aggregates). Needs `db:read:trips`. */
listMine(): Promise<Trip[]>;
/** 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>;
/** 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<Trip>;
/** The trip's member roster (id + display fields only). Membership-checked. Needs `db:read:trips`. */
members(tripId: number): Promise<User[]>;
/** 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<Reservation[]>;
/** 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<string, unknown>): Promise<Reservation>;
/** Update a booking. `endpoints` semantics: omitted = keep, [] = delete all,
* array = replace (endpoint ids are NOT stable). */
update(tripId: number, reservationId: number, input: Record<string, unknown>): Promise<Reservation>;
delete(tripId: number, reservationId: number): Promise<{ deleted: boolean }>;
};
// Lodging blocks (day_accommodations): a hotel span from a start day to an end day.
// Reads ride on `db:read:trips` (ctx.trips.getAccommodations); writes need
// `db:write:accommodations` + the acting user's day_edit permission (like the REST
// path — accommodations live in the day service, not the bookings one). Creating one
// auto-creates its partner hotel reservation, exactly like the app.
accommodations: {
create(tripId: number, input: { place_id: number; start_day_id: number; end_day_id: number; check_in?: string | null; check_in_end?: string | null; check_out?: string | null; confirmation?: string | null; notes?: string | null }): Promise<unknown>;
update(tripId: number, accommodationId: number, input: Record<string, unknown>): Promise<unknown>;
delete(tripId: number, accommodationId: number): Promise<{ deleted: boolean }>;
};
packing: {
/** A trip's packing items (hydrated bags/assignees). Needs `db:read:packing`. */
list(tripId: number): Promise<PackingItem[]>;
/** 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<PackingItem>;
/** Update a packing item. Needs `db:write:packing` + packing_edit. */
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<PackingItem>;
/** 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<unknown[]>;
createBag(tripId: number, input: { name: string; color?: string; weight_limit_grams?: number }): Promise<unknown>;
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
deleteBag(tripId: number, bagId: number): Promise<{ deleted: boolean }>;
setBagMembers(tripId: number, bagId: number, userIds: number[]): Promise<unknown>;
};
files: {
/** A trip's files, trash excluded. Needs `db:read:files`. */
list(tripId: number): Promise<TripFile[]>;
/** 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<TripFile>;
/** Link an existing file to a same-trip reservation/place/assignment. Needs `db:write:files` + file_edit. */
createLink(tripId: number, fileId: number, opts: { reservation_id?: number; assignment_id?: number; place_id?: number }): Promise<unknown>;
/** Update a file's description/links. Needs `db:write:files` + file_edit. */
update(tripId: number, fileId: number, input: { description?: string; place_id?: number | null; reservation_id?: number | null }): Promise<TripFile>;
/** Move a file to the trash. Needs `db:write:files` + file_delete. */
softDelete(tripId: number, fileId: number): Promise<{ deleted: boolean }>;
};
/** Collab content (notes/polls/chat). Reads need `db:read:collab`; writes need `db:write:collab` + the acting user's collab_edit. Both need the Collab addon. */
collab: {
/** A trip's collab notes (hydrated with author + attachments). Needs `db:read:collab`. */
listNotes(tripId: number): Promise<unknown[]>;
/** A trip's polls (with options + voters). Needs `db:read:collab`. */
listPolls(tripId: number): Promise<unknown[]>;
/** A trip's chat messages (newest 100, oldest first; `before` = a message id to page back). Needs `db:read:collab`. */
listMessages(tripId: number, before?: number): Promise<unknown[]>;
createNote(tripId: number, input: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }): Promise<unknown>;
createPoll(tripId: number, input: { question: string; options: unknown[]; multiple?: boolean; deadline?: string }): Promise<unknown>;
votePoll(tripId: number, pollId: number, optionIndex: number): Promise<unknown>;
createMessage(tripId: number, text: string, replyTo?: number): Promise<unknown>;
};
/** Host-mediated notification. The plugin supplies only target + plain text; the host
* owns delivery + preferences. Recipient is FORCED to the acting user (scope 'user',
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`.
* 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<string, unknown>[] }>;
};
/** Host-brokered outbound OAuth: a short-lived access token for the ACTING USER of a
* third-party service the host connected on their behalf (Settings → Plugins → Connect).
* Returns null when the user hasn't connected or in a userless context. The host holds
* the refresh token + client secret — you never see them. Needs `oauth:client`.
*
* 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<string | null>;
};
/** Persistent, userless scheduling — a future callback into your `scheduled` handler
* that survives restarts. Same risk class + grant as background jobs: needs `jobs:run`,
* runs with NO acting user (trip reads refused; own db + declared egress only). Caps:
* 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<unknown>;
};
/** Exchange rates (tenant-free, cached upstream). Needs `rates:read`. */
rates: {
/** A map of quote → rate relative to `base` (e.g. base 'EUR' → { USD: 1.08, ... }). `null` on an upstream failure. */
get(base: string): Promise<Record<string, number> | null>;
};
/** The global place-category reference list (read-only). Needs `db:read:categories`. */
categories: {
list(): Promise<unknown[]>;
};
/** The acting user's own tags. Needs `db:read:tags` (list) / `db:write:tags` (create/update/delete). */
tags: {
list(): Promise<unknown[]>;
create(input: { name: string; color?: string }): Promise<unknown>;
update(tagId: number, input: { name?: string; color?: string }): Promise<unknown>;
delete(tagId: number): Promise<{ deleted: boolean }>;
};
/** A trip's to-dos. Needs `db:read:todos` (list) / `db:write:todos` + packing_edit (create/update/delete). */
todos: {
list(tripId: number): Promise<unknown[]>;
create(tripId: number, input: { name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number }): Promise<unknown>;
update(tripId: number, todoId: number, input: Record<string, unknown>): Promise<unknown>;
delete(tripId: number, todoId: number): Promise<{ deleted: boolean }>;
};
// The acting user's OWN subsystem data across all their trips (not one trip), each
// gated on its addon being enabled — mirrors the addon's own REST/MCP readers.
journal: {
/** The acting user's journals. Needs `db:read:journal` + the journey addon. */
listMine(): Promise<unknown[]>;
/** The entries of one of the acting user's journeys (photos/story/checkins), access-checked. Needs `db:read:journal`. */
getEntries(journeyId: number): Promise<unknown[]>;
/** Create an entry on a journey the acting user can edit. Needs `db:write:journal`. */
createEntry(journeyId: number, input: { entry_date: string; [k: string]: unknown }): Promise<unknown>;
/** 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<unknown>;
/** 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<string, unknown>): Promise<unknown>;
/**
* 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<unknown>;
/** 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<unknown[]>;
/** Mark/unmark the ACTING USER's own visited countries/regions + bucket list. Needs `db:write:atlas`. */
markCountry(code: string): Promise<unknown>;
unmarkCountry(code: string): Promise<unknown>;
markRegion(regionCode: string, countryCode: string, regionName?: string): Promise<unknown>;
unmarkRegion(regionCode: string): Promise<unknown>;
createBucketItem(input: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string }): Promise<unknown>;
deleteBucketItem(itemId: number): Promise<{ deleted: boolean }>;
};
vacay: {
/** The acting user's vacation plan data. Needs `db:read:vacay` + the vacay addon. */
mine(): Promise<unknown>;
/** Toggle the ACTING USER's own PTO day on their active plan. Needs `db:write:vacay`. */
toggleEntry(date: string): Promise<{ action: string }>;
/** Toggle a company holiday on the acting user's active plan. Needs `db:write:vacay`. */
toggleCompanyHoliday(date: string, note?: string): Promise<{ action: string }>;
};
collections: {
/** The acting user's saved-place collections. Needs `db:read:collections` + the collections addon. */
listMine(): Promise<unknown>;
/** One of the acting user's collections by id. Needs `db:read:collections` + the collections addon. */
get(id: number): Promise<unknown>;
/** Collections write (per-collection role enforced by the service). Needs `db:write:collections`. */
create(input: Record<string, unknown>): Promise<unknown>;
update(id: number, input: Record<string, unknown>): Promise<unknown>;
savePlace(input: Record<string, unknown>): Promise<unknown>;
copyToTrip(input: Record<string, unknown>): Promise<unknown>;
deletePlace(placeId: number): Promise<{ deleted: boolean }>;
};
daynotes: {
/** A day's notes on a trip (membership-checked). Needs `db:read:daynotes`. */
list(tripId: number, dayId: number): Promise<unknown[]>;
/** Add a note to a day. Needs `db:write:daynotes` + the acting user's day_edit. */
create(tripId: number, dayId: number, input: Record<string, unknown>): Promise<unknown>;
/** Update a day note. Needs `db:write:daynotes` + day_edit. */
update(tripId: number, dayId: number, noteId: number, input: Record<string, unknown>): Promise<unknown>;
/** Delete a day note. Needs `db:write:daynotes` + day_edit. */
delete(tripId: number, dayId: number, noteId: number): Promise<{ deleted: boolean }>;
};
// "Costs" = budget items. 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. 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<unknown>;
set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise<unknown>;
list(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number): Promise<Record<string, unknown>>;
delete(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<{ deleted: boolean }>;
};
users: { getById(id: number): Promise<User | null> };
ws: {
broadcastToTrip(tripId: number, event: string, data: Record<string, unknown>): Promise<void>;
broadcastToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void>;
};
log: {
info(msg: string, meta?: Record<string, unknown>): void;
warn(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 {
method: string;
path: string;
query: Record<string, unknown>;
body: unknown;
/** Inbound headers — ONLY populated on `auth:false` routes (webhooks), and only an
* explicit, credential-free allowlist (signature + event headers from the common
* providers; never Cookie/Authorization/session). Empty on authenticated routes.
* Verify a provider signature against a secret you hold in `ctx.config`/`ctx.settings`. */
headers: Record<string, string>;
/**
* 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<string, string>;
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<PluginResponse>;
}
export interface PluginJob {
id: string;
/** Cron expression; the host owns the schedule. */
schedule: string;
handler(ctx: PluginContext): Promise<void>;
}
// ── 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<Photo | null>;
}
/**
* 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<string, unknown>, ctx: PluginContext): Promise<void>;
/** Backs the "Send test" button in the user's notification settings. */
test?(config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
}
/**
* 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<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`.
* The host caps results at 12 items per provider. */
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/**
* 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:<yourId>:<id>`. */
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<SearchResultPlace[]>;
/** 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<SearchResultPlace[]>;
}
/**
* 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:<yourId>:<id>`. */
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<PoiCategoryPlace[]>;
}
/** 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<TripWarning[]>;
}
/** 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<TableContribution[]>;
}
/** A bounded marker the host renders onto the trip map (#587). Declarative only —
* the host draws a Leaflet marker + popup; plugin JS never runs on the map canvas. */
export interface MapMarkerContribution {
id: string; // stable per-marker id (React key / dedupe)
lat: number; // -90..90
lng: number; // -180..180
label?: string; // short label shown in the popup title
popupText?: string; // one line of body text (host-sanitized, plain text)
url?: string; // http/https/mailto only — the host rejects any other scheme
icon?: string; // a lucide icon name, resolved by the host
tone?: ContributionTone;
}
export interface MapMarkerProvider {
/** Return markers to overlay on a trip's map. Runs with the current user bound,
* on a short timeout; the host caps the marker count and skips a failing call.
* Needs `hook:map-marker-provider`. The host caps results at 200 markers per provider. */
getMarkers(tripId: number, ctx: PluginContext): Promise<MapMarkerContribution[]>;
}
/** 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<MapLayerContribution[]>;
}
/** 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<RouteProviderResult>;
}
/** 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<DayScheduleContribution[]>;
}
/** 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<DayTintContribution[]>;
}
/** 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<PdfSection[]>;
}
/** One country in an Atlas tint layer. `code` is ISO-3166 alpha-2 (uppercased by the host). */
export interface AtlasLayerCountry { code: string; tone?: ContributionTone; label?: string; }
/** A country tint layer the host draws over the Atlas world map (wishlists, advisories, …). */
export interface AtlasLayer {
id: string; // stable per-layer id (React key / dedupe)
name?: string; // short layer name
countries: AtlasLayerCountry[]; // ≤300 countries per layer
}
export interface AtlasLayerProvider {
/** Return tint layers for the ACTING USER's Atlas map. User-scoped — the host binds
* the current user; the hook takes no target parameter. Runs on a short timeout;
* the host caps the layer/country counts and skips a failing call.
* 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<AtlasLayer[]>;
}
/** 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<TripCardContribution[]>;
}
/** 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<JournalEntryRow[]>;
}
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job).
* Needs `events:subscribe`. */
export interface PluginEventSubscription {
/** A core event name `<family>:<verb>` (e.g. `place:created`, `day:updated`, `file:created`)
* where `<family>` 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<string, unknown> }, 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;
}
/**
* 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_<id>_` 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> | unknown;
}
export interface PluginDefinition {
onLoad?(ctx: PluginContext): Promise<void> | void;
onUnload?(ctx: PluginContext): Promise<void> | void;
routes?: PluginRoute[];
jobs?: PluginJob[];
/** Handles a callback registered via ctx.scheduler (userless, like a job). The
* `name` identifies which scheduled task fired; `payload` is what you passed. */
scheduled?(input: { name: string; payload: unknown }, ctx: PluginContext): Promise<void> | void;
/** GDPR erasure: a TREK account was deleted — remove everything you hold about it
* from your OWN db. Userless (no acting user). Needs `hook:user-data`. The host
* calls this durably (queued, retried until it succeeds), so make it idempotent. */
deleteUserData?(input: { userId: number }, ctx: PluginContext): Promise<void> | void;
/** GDPR portability: return the data you hold about a user (own db only), as a
* JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
events?: PluginEventSubscription[];
/**
* 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<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | 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<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. */
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';
// What ctx results carry: the published fields of each entity, and what each method returns.
export { PLUGIN_ENTITY_FIELDS, PLUGIN_METHOD_RESULT } 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<T = unknown>(key: string, options?: PluginSessionStorageOptions): Promise<T | undefined>;
set(key: string, value: unknown, options?: PluginSessionStorageOptions): Promise<void>;
remove(key: string, options?: PluginSessionStorageOptions): Promise<void>;
clear(options?: PluginSessionStorageOptions): Promise<void>;
}
// 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';