mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
465480630b
The time budget for an exec script was read after every statement, the last one included. A single statement without args, or a script whose closing COMMIT crossed the budget, had already run and committed, yet the call threw a time budget error. A plugin that retries on error then wrote the same rows a second time. The script now reads the clock before each statement after the first, so it still stops before the next statement once the time is up and rolls back a transaction it opened, but a script whose statements have all run is reported as done. query() likewise reads the clock only once another row has come back, so a result read to its end is returned. The ctx.db doc in the SDK said a single statement was not timed, which contradicted the query budget. It now names what is really untimed: an exec with bound args and migrate. README and wiki describe the script stopping before its next statement.
1044 lines
59 KiB
TypeScript
1044 lines
59 KiB
TypeScript
/**
|
||
* 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, and a `query`, a `tx` or an `exec` script
|
||
* without args gets 2 s of wall-clock time, checked between the rows it reads and
|
||
* the statements it runs (past it, it throws, a `tx` rolls back and a script stops
|
||
* before its next statement). An `exec` with bound args (one statement) and
|
||
* `migrate` are not timed, and no single statement can be stopped midway (a read
|
||
* only between its rows).
|
||
* See `tx()` below for the atomic-batch cap and README § Runtime limits. */
|
||
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, the fields holding rows
|
||
// of another entity, and what each method returns.
|
||
export { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, 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';
|