mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
a4972f8f13
Covers the pieces added in the previous commits, in the pages a reader would actually reach for: - Plugins.md (the admin-facing page) had none of it: notification channels, settings actions, and a full "Allowed hosts" section — including what operator-supplied egress deliberately does NOT let anyone do. - Plugin-Development.md: the notificationChannel hook (and why it is the one hook with no acting user), settings-page actions, operatorEgress, and the manifest reference rows. - Plugin-Cookbook.md: a "become a notification channel" recipe and a "Test connection button" recipe. - Plugin-Permissions.md: hook:notification-channel, operatorEgress under the outbound section, and settings actions under "not a permission". - Notifications.md: plugin channels alongside the four built-ins.
514 lines
20 KiB
Markdown
514 lines
20 KiB
Markdown
# Plugin Cookbook
|
|
|
|
Short, copy-paste recipes for the things plugins can do. Each one names the
|
|
permission it needs (declare it in `trek-plugin.json` → `permissions`) and the
|
|
capability it uses. For the full API see [[Plugin Development|Plugin-Development]];
|
|
for the permission catalogue see [[Plugin Permissions|Plugin-Permissions]].
|
|
|
|
> Every trip/place/day operation is **membership-checked by the host** against the
|
|
> user bound to the invocation — your plugin never passes a user id. Writes also
|
|
> need that user's `*_edit` permission. A read/write you're not allowed to do fails
|
|
> loudly; it never silently escalates.
|
|
|
|
The complete, runnable version of these recipes is the
|
|
[`trip-doctor`](https://github.com/mauriceboe/TREK/tree/main/plugin-sdk/examples/trip-doctor)
|
|
example plugin.
|
|
|
|
---
|
|
|
|
## Read a trip's places and bookings
|
|
|
|
**Needs:** `db:read:trips`
|
|
|
|
```js
|
|
async handler(req, ctx) {
|
|
const places = await ctx.trips.getPlaces(Number(req.query.tripId))
|
|
const bookings = await ctx.trips.getReservations(Number(req.query.tripId))
|
|
// …use them; the host already checked req.user can see this trip
|
|
}
|
|
```
|
|
|
|
## Read the packing list or files of a trip
|
|
|
|
**Needs:** `db:read:packing` · `db:read:files` (declare only what you use)
|
|
|
|
```js
|
|
const packing = await ctx.packing.list(tripId) // hydrated bags/assignees
|
|
const files = await ctx.files.list(tripId) // trash excluded
|
|
```
|
|
|
|
Both are membership-checked against the current user — same gate as `ctx.trips.*`.
|
|
|
|
## Add / move something on the itinerary
|
|
|
|
**Needs:** `db:write:places` · `db:write:days` · `db:write:itinerary` (declare only what you use)
|
|
|
|
```js
|
|
const place = await ctx.places.create(tripId, { name: 'Teamlab', lat: 35.62, lng: 139.78 })
|
|
const day = await ctx.days.create(tripId, { date: '2027-04-02', notes: 'Odaiba' })
|
|
await ctx.itinerary.assign(tripId, day.id, place.id, 'buy tickets first')
|
|
// days.create accepts { date?, notes?, position? }; set a day title later with ctx.days.update(tripId, day.id, { title: 'Odaiba' }).
|
|
```
|
|
|
|
Updates and deletes mirror the REST app exactly (`ctx.places.update/delete`,
|
|
`ctx.days.update/delete`, `ctx.itinerary.unassign`). They broadcast the same live
|
|
event, so open web sessions update instantly.
|
|
|
|
## Tag a core entity — no schema fork
|
|
|
|
**Needs:** `db:meta`
|
|
|
|
Store your own JSON-serialisable data on a trip, place or day. Rows are namespaced
|
|
to **your plugin id** — no other plugin can read or overwrite them.
|
|
|
|
```js
|
|
await ctx.meta.set('place', placeId, 'lastCheckedAt', Date.now())
|
|
const when = await ctx.meta.get('place', placeId, 'lastCheckedAt')
|
|
const all = await ctx.meta.list('place', placeId) // { lastCheckedAt: 172… }
|
|
await ctx.meta.delete('place', placeId, 'lastCheckedAt')
|
|
```
|
|
|
|
Reads need trip access; writes additionally need the entity's edit permission.
|
|
|
|
## Contribute extra info to a place (rendered natively)
|
|
|
|
**Needs:** `hook:place-detail-provider`
|
|
|
|
Return rows and TREK draws them at the foot of the place panel — no iframe.
|
|
|
|
```js
|
|
module.exports = {
|
|
hooks: {
|
|
placeDetailProvider: {
|
|
async getDetails(placeId, ctx) {
|
|
return [
|
|
{ label: 'Crowd', value: 'Quiet right now' },
|
|
{ label: 'Official site', url: 'https://…' },
|
|
]
|
|
},
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
## Raise validation warnings on a trip
|
|
|
|
**Needs:** `hook:trip-warning-provider`
|
|
|
|
Return problems and they show as a non-blocking banner in the planner.
|
|
|
|
```js
|
|
hooks: {
|
|
warningProvider: {
|
|
async getWarnings(tripId, ctx) {
|
|
const places = await ctx.trips.getPlaces(tripId)
|
|
return places
|
|
.filter((p) => p.lat == null)
|
|
.map((p) => ({ level: 'warning', message: `"${p.name}" has no location`, placeId: p.id }))
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
`level` is `'info' | 'warning' | 'error'`; `dayId`/`placeId` are optional anchors.
|
|
|
|
## Push a live update to a trip / a user
|
|
|
|
**Needs:** `ws:broadcast:trip` and/or `ws:broadcast:user`
|
|
|
|
```js
|
|
ctx.ws.broadcastToTrip(tripId, 'doctor:rechecked', { count }) // → plugin:<id>:doctor:rechecked
|
|
ctx.ws.broadcastToUser(userId, 'nudge', { text: '…' }) // (userId, event, data) — only that user
|
|
```
|
|
|
|
Events are automatically namespaced to `plugin:<your-id>:…` so they can't collide
|
|
with core events.
|
|
|
|
## React to core activity
|
|
|
|
**Needs:** `events:subscribe`
|
|
|
|
Handlers run with no user and get the event name + tripId only (never the payload).
|
|
|
|
```js
|
|
events: [
|
|
{ on: 'file:created', async handler({ tripId }, ctx) {
|
|
await notifySlack(`New file on trip ${tripId}`) // needs http:outbound
|
|
} },
|
|
]
|
|
```
|
|
|
|
Fire-and-forget on a short timeout — never blocks a core write. Trip reads are
|
|
refused (no user); use `ctx.db`, `ctx.ws.*`, or an outbound call. Your own
|
|
`plugin:*` broadcasts are never re-delivered, so handlers can't loop.
|
|
|
|
## Depend on another plugin — call it and hear its events
|
|
|
|
**Needs:** a `pluginDependencies` entry for the other plugin (no permission).
|
|
|
|
Expose a contract from the **dependency** (declare the names in
|
|
`capabilities.provides` / `capabilities.emits`):
|
|
|
|
```js
|
|
// plugin "koffi" · manifest: "capabilities": { "provides": ["convert"], "emits": ["rate.updated"] }
|
|
exports: {
|
|
async convert({ amount, from, to }) { return { amount: amount * rate(from, to), to } },
|
|
},
|
|
async onLoad(ctx) { ctx.events.emit('rate.updated', { pair: 'USD/EUR' }) },
|
|
```
|
|
|
|
Consume it from the **dependent** (declare koffi as a dependency):
|
|
|
|
```js
|
|
// manifest: "pluginDependencies": [{ "id": "koffi", "version": ">=1.0.0 <2.0.0" }]
|
|
routes: [
|
|
{ method: 'GET', path: '/price', async handler(_req, ctx) {
|
|
const out = await ctx.plugins.call('koffi', 'convert', { amount: 10, from: 'USD', to: 'EUR' })
|
|
return { status: 200, body: JSON.stringify(out) }
|
|
} },
|
|
],
|
|
subscriptions: [
|
|
{ plugin: 'koffi', event: 'rate.updated', async handler(payload, ctx) { ctx.log.info('rates changed', payload) } },
|
|
],
|
|
```
|
|
|
|
TREK auto-enables koffi before your plugin, routes the call (as your acting user),
|
|
and refuses it if koffi isn't a satisfied dependency or doesn't export `convert`. See
|
|
[[Plugin Development#talking-to-other-plugins|Plugin-Development]].
|
|
|
|
## Match the TREK look
|
|
|
|
Add `<!-- trek:ui -->` to your widget's `<head>`. The dev server and `pack` inline
|
|
TREK's token-driven kit (glass surfaces, buttons, inputs, dark-mode) and a
|
|
`window.trek` bridge with the live theme + tokens. See
|
|
[[Plugin Development#the-design-kit-recommended|Plugin-Development]]. `window.trek.ui`
|
|
gives you bundler-free, kit-styled DOM builders (`ui.el/button/card/chip/input/mount`).
|
|
|
|
---
|
|
|
|
## Notify a user or a trip
|
|
|
|
**Needs:** `notify:send`
|
|
|
|
The plugin supplies target + plain text; the host owns delivery and the user's notification preferences.
|
|
|
|
```js
|
|
await ctx.notify.send({
|
|
title: 'Trip rechecked',
|
|
body: '3 places still need a location',
|
|
scope: 'trip', targetId: tripId, // or scope:'user', targetId: the acting user
|
|
link: '/trips/' + tripId,
|
|
})
|
|
```
|
|
|
|
The recipient is **forced** by the host: `scope:'user'` can only reach the acting user, `scope:'trip'` only a trip they belong to. You can't notify anyone else.
|
|
|
|
---
|
|
|
|
## Become a notification channel (Gotify, Pushover, …)
|
|
|
|
**Needs:** `hook:notification-channel` + `http:outbound:<host>` (and a matching `egress`)
|
|
|
|
The recipe above *produces* a notification. This one **delivers** one — your plugin becomes a
|
|
channel next to email / webhook / ntfy in the user's notification preferences.
|
|
|
|
```bash
|
|
npx create-trek-plugin my-gotify --type integration --template notification-channel
|
|
```
|
|
|
|
```js
|
|
module.exports = definePlugin({
|
|
hooks: {
|
|
notificationChannel: {
|
|
async send(msg, config, ctx) {
|
|
// msg is ALREADY rendered in the recipient's language, deep link included.
|
|
// config is that recipient's own scope:'user' settings, decrypted for you.
|
|
const res = await fetch(`${config.serverUrl}/message`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'X-Gotify-Key': config.appToken },
|
|
body: JSON.stringify({ title: msg.title, message: msg.body }),
|
|
})
|
|
if (!res.ok) throw new Error(`Gotify responded ${res.status}`) // host logs + isolates
|
|
},
|
|
async test(config) { /* backs the "Send test" button */ },
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```json
|
|
"permissions": ["hook:notification-channel", "http:outbound:gotify.example.com"],
|
|
"egress": ["gotify.example.com"],
|
|
"capabilities": { "notificationChannel": { "title": "Gotify" } },
|
|
"settings": [
|
|
{ "key": "serverUrl", "scope": "user", "required": true },
|
|
{ "key": "appToken", "scope": "user", "required": true, "secret": true }
|
|
]
|
|
```
|
|
|
|
The gotcha worth knowing up front: this hook is **host-initiated for an arbitrary recipient**, so
|
|
unlike every other hook it runs with **no acting user**. `ctx.settings.get()` returns `undefined`
|
|
and trip reads are refused — the recipient's credentials come in as `config` instead. That's the
|
|
whole point: you can be handed someone's push token without being handed their trips.
|
|
|
|
Targeting a **self-hosted** Gotify? Your manifest can't name the user's hostname, so add
|
|
`"operatorEgress": true` and let the admin add the real host after install
|
|
(Admin → Plugins → Allowed hosts). See
|
|
[Plugin-Development → Operator-supplied egress hosts](Plugin-Development.md#operator-supplied-egress-hosts-operatoregress),
|
|
plus [Notification channels](Plugin-Development.md#notification-channels) for the event list, the
|
|
"configured" rule, and the `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS` flag you need if the service runs
|
|
on your own LAN.
|
|
|
|
---
|
|
|
|
## Put a "Test connection" button on your settings page
|
|
|
|
**Needs:** nothing — an action is your own code, run for the user who clicked it.
|
|
|
|
Declare the buttons in the manifest, implement them on the definition:
|
|
|
|
```json
|
|
"actions": [
|
|
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
|
|
{ "key": "purge", "label": "Delete my data", "danger": true }
|
|
]
|
|
```
|
|
|
|
```js
|
|
module.exports = definePlugin({
|
|
actions: {
|
|
async testConnection(ctx) {
|
|
// USER-INITIATED: the acting user is whoever clicked, so ctx.settings.get()
|
|
// returns THEIR value — which is what makes "test MY credentials" possible.
|
|
const token = await ctx.settings.get('appToken')
|
|
const res = await fetch('https://api.example.com/ping', { headers: { authorization: token } })
|
|
return { ok: res.ok, message: res.ok ? 'Connected' : `Failed: ${res.status}` }
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
The buttons render under your fields in **Settings → Plugins**, with the result shown
|
|
beside them. Return `{ ok, message? }`; throwing is the same as `{ ok: false }` carrying
|
|
the error text. `danger: true` asks for confirmation first. The host refuses any key your
|
|
manifest didn't declare, and bounds the message it shows (200 chars, emoji stripped).
|
|
|
|
Contrast with the `notificationChannel` hook, which is **host**-initiated and therefore
|
|
has *no* acting user — there, `ctx.settings.get()` returns `undefined` and the recipient's
|
|
credentials arrive as an argument instead.
|
|
|
|
---
|
|
|
|
## Ask the configured LLM
|
|
|
|
**Needs:** `ai:invoke`
|
|
|
|
Uses whatever provider the admin/user set up — your plugin never holds a key.
|
|
|
|
```js
|
|
const { text } = await ctx.ai.complete('Summarise this trip in one line', 'You are a concise travel assistant')
|
|
|
|
const { results } = await ctx.ai.extract(
|
|
pastedBookingText,
|
|
{ type: 'object', properties: { hotel: { type: 'string' }, checkIn: { type: 'string' } } },
|
|
)
|
|
```
|
|
|
|
Output is **data**. To store it, push it through a gated write yourself (e.g. `ctx.reservations.create`).
|
|
|
|
---
|
|
|
|
## Call a third-party API the user connected
|
|
|
|
**Needs:** `oauth:client` (and `http:outbound` for the fetch)
|
|
|
|
The user connects the service under Settings → Plugins → Connect. The host keeps the refresh token and client secret; you only ever get a short-lived access token for the acting user.
|
|
|
|
```js
|
|
const token = await ctx.oauth.getAccessToken() // null if not connected, or in a userless context
|
|
if (token) {
|
|
const res = await fetch('https://api.example.com/me', { headers: { Authorization: `Bearer ${token}` } })
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Convert currencies
|
|
|
|
**Needs:** `rates:read`
|
|
|
|
```js
|
|
const rates = await ctx.rates.get('EUR') // { USD: 1.08, GBP: 0.85, … } — or null on an upstream failure
|
|
const usd = 100 * rates.USD
|
|
```
|
|
|
|
Tenant-free and cached upstream — no trip access needed.
|
|
|
|
---
|
|
|
|
## Run work on a schedule
|
|
|
|
**Needs:** `jobs:run`
|
|
|
|
Two flavours: a fixed **cron job** declared in the manifest, or a **dynamic timer** you set at runtime via `ctx.scheduler`.
|
|
|
|
```js
|
|
module.exports = {
|
|
// fixed cron — runs on node-cron
|
|
jobs: [
|
|
{ id: 'nightly', schedule: '0 3 * * *', async handler(ctx) { /* … */ } },
|
|
],
|
|
|
|
// dynamic timers fire back into `scheduled`
|
|
scheduled(input, ctx) {
|
|
ctx.log.info('fired', { name: input.name, payload: input.payload })
|
|
},
|
|
|
|
routes: [
|
|
{ method: 'POST', path: '/remind', async handler(req, ctx) {
|
|
await ctx.scheduler.in(60_000, 'remind', { tripId: 1 })
|
|
// also: .at(epochMs, name, payload) · .every(ms, name, payload) · .cancel(name)
|
|
return { status: 200 }
|
|
} },
|
|
],
|
|
}
|
|
```
|
|
|
|
Both jobs and scheduled tasks run with **no acting user** (trip reads are refused; only your own db + declared egress). `ctx.scheduler.set` is an upsert by name and survives restarts. Caps: ≤100 tasks/plugin, ≤128-char name, ≤8 KB payload, recurring interval ≥60s, ≤1 year out.
|
|
|
|
---
|
|
|
|
## Contribute native primitives to core views
|
|
|
|
Six declarative hooks let a plugin push data into TREK's own screens — the host renders and sanitizes everything, so no iframe and no plugin JS on the canvas. Each needs its own `hook:*` permission, runs with the current user bound on a short timeout, and a slow or failing call is skipped (never fatal). The host caps counts and lengths.
|
|
|
|
```js
|
|
hooks: {
|
|
// hook:table-contributor — cells/buttons on a row. view ∈ reservations|places|day|costs|packing|files
|
|
tableContributor: { async getContributions(view, tripId, ctx) {
|
|
return [{ kind: 'column', entityId: 42, id: 'crowd', label: 'Crowd', value: 'Quiet', tone: 'success' }]
|
|
} },
|
|
|
|
// hook:map-marker-provider — pins on the trip map (#587)
|
|
mapMarkerProvider: { async getMarkers(tripId, ctx) {
|
|
return [{ id: 'm1', lat: 35.62, lng: 139.78, label: 'Teamlab', popupText: 'Opens 10:00' }]
|
|
} },
|
|
|
|
// hook:pdf-section-provider — text sections appended to the trip PDF export
|
|
pdfSectionProvider: { async getSections(tripId, ctx) {
|
|
return [{ title: 'Notes', paragraphs: ['Bring cash.'], table: { headers: ['Day', 'Plan'], rows: [['1', 'Odaiba']] } }]
|
|
} },
|
|
|
|
// hook:atlas-layer-provider — country tint layers on the Atlas map (user-scoped, no target arg)
|
|
atlasLayerProvider: { async getLayers(ctx) {
|
|
return [{ id: 'wishlist', name: 'Wishlist', countries: [{ code: 'JP', tone: 'warn' }] }]
|
|
} },
|
|
|
|
// hook:journal-entry-provider — extra rows under a journal entry
|
|
journalEntryProvider: { async getRows(entryId, ctx) {
|
|
return [{ label: 'Weather', value: 'Sunny 22°C' }]
|
|
} },
|
|
|
|
// hook:trip-card-provider — badges on dashboard trip cards (tripIds = the cards on screen)
|
|
tripCardProvider: { async getCards(tripIds, ctx) {
|
|
return tripIds.map((id) => ({ tripId: id, id: 'days', label: 'Days left', value: '12' }))
|
|
} },
|
|
}
|
|
```
|
|
|
|
`tone` is `'default' | 'success' | 'warn' | 'danger'`; any `url` must be http/https/mailto; `icon` is a lucide icon name. A table `action` (instead of a `column`) is a labelled button whose target opens your sandboxed frame or calls one of your routes.
|
|
|
|
---
|
|
|
|
## Honour account deletion and data export (GDPR)
|
|
|
|
**Needs:** `hook:user-data`
|
|
|
|
The host calls these when a TREK account is erased or its data is exported. Both are **userless** — you get only a `userId` and act on your OWN db, so the grant leaks no read into core data.
|
|
|
|
```js
|
|
module.exports = {
|
|
// Erasure — called durably (queued, retried until it succeeds), so make it idempotent.
|
|
async deleteUserData({ userId }, ctx) {
|
|
await ctx.db.exec('DELETE FROM my_rows WHERE user_id = ?', userId)
|
|
},
|
|
// Portability — return a JSON-serialisable value the host aggregates into the export.
|
|
async exportUserData({ userId }, ctx) {
|
|
return await ctx.db.query('SELECT * FROM my_rows WHERE user_id = ?', userId)
|
|
},
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Write across every subsystem
|
|
|
|
Beyond places/days/itinerary, TREK 3.3.0 opens create/update/delete on almost every trip subsystem. Each is membership-checked against the acting user and needs its `db:write:*` scope **plus** the app's edit permission — declare only what you use.
|
|
|
|
```js
|
|
// Bookings & lodging — needs 'reservation_edit' / 'day_edit'
|
|
await ctx.reservations.create(tripId, { type: 'flight', title: 'NRT → HND', endpoints: [/* legs */] })
|
|
await ctx.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in: '15:00' })
|
|
// Packing & to-dos — needs 'packing_edit'
|
|
await ctx.packing.create(tripId, { name: 'Passport', visibility: 'personal' })
|
|
await ctx.todos.create(tripId, { name: 'Buy JR pass', due_date: '2027-04-01' })
|
|
// Costs (budget) — needs 'budget_edit' + the Costs addon
|
|
await ctx.costs.create(tripId, { title: 'Hotel', amount: 120, currency: 'EUR' })
|
|
// Collab notes/polls/chat — needs 'collab_edit' + the Collab addon
|
|
await ctx.collab.createNote(tripId, { title: 'Meeting point' })
|
|
// Day notes — needs 'day_edit'
|
|
await ctx.daynotes.create(tripId, dayId, { content: 'Rainy — swap plans' })
|
|
// Tags (the acting user's own)
|
|
const tag = await ctx.tags.create({ name: 'foodie', color: '#4F46E5' })
|
|
```
|
|
|
|
The matching reads mirror the app 1:1: `ctx.trips.getReservations/getAccommodations/getDays`, `ctx.packing.list`, `ctx.costs.getByTrip`, `ctx.collab.listNotes/listPolls/listMessages`, `ctx.daynotes.list`, `ctx.todos.list`, `ctx.tags.list`, `ctx.categories.list`.
|
|
|
|
---
|
|
|
|
## Write the acting user's own Atlas / Vacay / Journal / Collections
|
|
|
|
These belong to the **user** (not one trip), each gated on its addon being enabled.
|
|
|
|
```js
|
|
await ctx.atlas.markCountry('JP') // db:write:atlas
|
|
await ctx.atlas.createBucketItem({ name: 'See Mt Fuji', country_code: 'JP' })
|
|
await ctx.vacay.toggleEntry('2027-04-02') // db:write:vacay — toggles one PTO day
|
|
const j = await ctx.journal.createJourney({ title: 'Japan 2027' }) // db:write:journal
|
|
await ctx.journal.createEntry(j.id, { entry_date: '2027-04-02' })
|
|
const c = await ctx.collections.create({ name: 'Tokyo eats' }) // db:write:collections
|
|
await ctx.collections.savePlace({ collection_id: c.id, name: 'Ramen shop', lat: 35.6, lng: 139.7 })
|
|
```
|
|
|
|
Reads: `ctx.atlas.visited()/bucketList()`, `ctx.vacay.mine()`, `ctx.journal.listMine()/getEntries()`, `ctx.collections.listMine()/get()`.
|
|
|
|
---
|
|
|
|
## Batch writes atomically, and read per-user settings
|
|
|
|
**Needs:** `db:own` (for `db.tx`); `ctx.settings` needs no permission.
|
|
|
|
```js
|
|
// db.tx — all commit or all roll back, on your OWN db (≤100 statements; reads see the batch's earlier writes)
|
|
await ctx.db.tx([
|
|
{ sql: 'INSERT INTO cache(k, v) VALUES(?, ?)', args: ['a', 1] },
|
|
{ sql: 'UPDATE cache SET v = v + 1 WHERE k = ?', args: ['a'] },
|
|
])
|
|
|
|
// ctx.settings.get — the ACTING USER's own value for a scope:'user' settings field (decrypted host-side)
|
|
const apiKey = await ctx.settings.get('apiKey') // undefined when unset or userless → fall back to ctx.config
|
|
```
|
|
|
|
`ctx.config` is the admin-owned instance settings; `ctx.settings` is that user's private values for the `scope:'user'` fields you declared in the manifest.
|
|
|
|
---
|
|
|
|
## Where things run
|
|
|
|
| Surface | Runs | Gets |
|
|
|---|---|---|
|
|
| `routes` | forked server child | `ctx` bound to the HTTP request's user |
|
|
| `jobs` | forked server child, on a schedule | `ctx` with **no** user (can't read user-scoped data) |
|
|
| `hooks` | forked server child, when core asks | `ctx` bound to the user who triggered the read, short timeout |
|
|
| `widget` / `page` | sandboxed iframe (no same-origin) | `postMessage` bridge; calls its own routes via `trek:invoke` |
|