mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -30,9 +30,9 @@ Because the package ships standalone it cannot import server code, so several fi
|
||||
|
||||
| SDK copy | Source of truth | Parity test |
|
||||
|---|---|---|
|
||||
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` — **machine-written, never hand-edit** | server `check:plugin-facts` CI gate |
|
||||
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_METHOD_RESULT`) | `server/src/nest/plugins/protocol/envelope.ts`, `protocol/output-contract.ts` and `server/src/addons.ts` via `server/scripts/gen-plugin-facts.ts` — **machine-written, never hand-edit** | server `check:plugin-facts` CI gate; the entity interfaces in `src/index.ts` are type-checked against `PLUGIN_ENTITY_FIELDS` |
|
||||
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
|
||||
| `src/manifest.ts` (validation rules incl. the `trek` version range, `KNOWN_ADDONS`) | `server/src/nest/plugins/install/` + `server/src/addons.ts` | kept in sync by hand |
|
||||
| `src/manifest.ts` (validation rules incl. the `trek` version range) | `server/src/nest/plugins/install/` | kept in sync by hand |
|
||||
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; `TREK_PLUGINS_REPO` points at a checkout) |
|
||||
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format frozen against the reader |
|
||||
|
||||
|
||||
@@ -127,6 +127,34 @@ Your plugin runs in an **isolated child process**. `ctx` is the only way to reac
|
||||
TREK, and it grants exactly the permissions your `trek-plugin.json` declares — an
|
||||
ungranted call throws `PERMISSION_DENIED`.
|
||||
|
||||
### What a ctx call returns
|
||||
|
||||
Every ctx method falls into one of three kinds, listed per method in the exported
|
||||
`PLUGIN_METHOD_RESULT`:
|
||||
|
||||
- **An entity** (`trip`, `place`, `day`, `reservation`, `packingItem`, `tripFile`,
|
||||
`budgetItem`, `assignment`, `user`; a list where the entry ends in `[]`). Each row
|
||||
carries exactly the fields in `PLUGIN_ENTITY_FIELDS[entity]` and no others: the
|
||||
table's published columns in snake_case, then what TREK adds on top (for a trip
|
||||
`day_count`, `place_count`, `is_owner`, `owner_username` and `shared_count`; for a
|
||||
place `category`, `tags` and `ratings`; for a reservation `endpoints`, `travelers`
|
||||
and `day_positions`; for a day its `assignments` and `notes_items`). A field is only
|
||||
present when the method's read produces it, so `trips.getById` has no `day_count`
|
||||
while `trips.listMine` does. Credentials are never part of a row (a trip's
|
||||
calendar `feed_token`, any account data beyond a user's id, username, display name
|
||||
and avatar). A column TREK adds later is not delivered until TREK publishes it in
|
||||
this list, so a row never changes shape behind your back. The `Trip`, `Place`, ...
|
||||
interfaces name the commonly used fields and are checked against these lists when
|
||||
the SDK is built; `Place.day_id` is deprecated and never delivered, since a place's
|
||||
days are the `assignments` of `ctx.trips.getDays()`.
|
||||
- **`readModel`**: the app's own read model for that data (tags, to-dos, collab
|
||||
notes, journal entries, bags, collections, accommodations and the like), passed on
|
||||
as TREK's REST route returns it. These are typed `unknown`; treat a field you did
|
||||
not check for as optional.
|
||||
- **`host`**: a value the host builds itself (`{ deleted }`, `{ sent }`, a model's
|
||||
answer, an access token) or data you own (`ctx.db`, `ctx.meta`, another plugin's
|
||||
answer), returned as it is.
|
||||
|
||||
## Settings
|
||||
|
||||
Declare settings in `trek-plugin.json`; TREK renders the form, you write no settings UI.
|
||||
@@ -347,6 +375,7 @@ npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answ
|
||||
|
||||
- `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`).
|
||||
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
|
||||
- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_METHOD_RESULT`: what each ctx method returns and which fields each entity row carries (see [What a ctx call returns](#what-a-ctx-call-returns)).
|
||||
- `validateManifest(json)` — the manifest rules the server loader uses.
|
||||
- `settingDefaults(manifest, scope)` — the `default`s of one settings scope, keyed by field: what the host resolves for an unset field, and what `dev` seeds `ctx.config` / `ctx.settings` with. Spread it under your own fixtures when calling `createMockHost` to mirror the host. `SETTING_FIELD_KEYS` is the attribute list the host stores.
|
||||
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// GENERATED — do not edit by hand.
|
||||
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
|
||||
// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts
|
||||
// + shared/src/plugins/plugin-poi-facts.ts
|
||||
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
|
||||
|
||||
@@ -388,3 +389,360 @@ export const POI_CATEGORY_ICONS: string[] = [
|
||||
|
||||
export const POI_CATEGORY_MAX = 4;
|
||||
export const POI_CATEGORY_LABEL_MAX = 40;
|
||||
|
||||
/**
|
||||
* The addon ids a manifest's `requiredAddons` may name: TREK's ADDON_IDS.
|
||||
*/
|
||||
export const KNOWN_ADDONS: string[] = [
|
||||
'mcp',
|
||||
'packing',
|
||||
'budget',
|
||||
'documents',
|
||||
'vacay',
|
||||
'atlas',
|
||||
'collab',
|
||||
'journey',
|
||||
'airtrail',
|
||||
'dawarich',
|
||||
'llm_parsing',
|
||||
'collections',
|
||||
'roadtrip',
|
||||
'tours',
|
||||
];
|
||||
|
||||
/**
|
||||
* The fields each entity result carries, and nothing else: the row's published
|
||||
* columns, then the keys the host adds (joined names, counts, hydrated children).
|
||||
* A column TREK adds later is not delivered until it is listed here. A literal,
|
||||
* unlike the wide tables above, because it is new (nothing published depends on a
|
||||
* wider type) and the SDK's entity interfaces are type-checked against it.
|
||||
*/
|
||||
export const PLUGIN_ENTITY_FIELDS = {
|
||||
trip: [
|
||||
'id',
|
||||
'user_id',
|
||||
'title',
|
||||
'description',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'currency',
|
||||
'cover_image',
|
||||
'is_archived',
|
||||
'reminder_days',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'reminder_sent_for',
|
||||
'day_count',
|
||||
'place_count',
|
||||
'is_owner',
|
||||
'owner_username',
|
||||
'shared_count',
|
||||
],
|
||||
place: [
|
||||
'id',
|
||||
'trip_id',
|
||||
'name',
|
||||
'description',
|
||||
'lat',
|
||||
'lng',
|
||||
'address',
|
||||
'category_id',
|
||||
'price',
|
||||
'currency',
|
||||
'reservation_status',
|
||||
'reservation_notes',
|
||||
'reservation_datetime',
|
||||
'place_time',
|
||||
'end_time',
|
||||
'duration_minutes',
|
||||
'notes',
|
||||
'image_url',
|
||||
'google_place_id',
|
||||
'google_ftid',
|
||||
'website',
|
||||
'phone',
|
||||
'transport_mode',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'osm_id',
|
||||
'route_geometry',
|
||||
'route_color',
|
||||
'stop_type',
|
||||
'fill_percent',
|
||||
'amap_poi_id',
|
||||
'source',
|
||||
'email',
|
||||
'opening_hours',
|
||||
'category_name',
|
||||
'category_color',
|
||||
'category_icon',
|
||||
'tour_place_id',
|
||||
'category',
|
||||
'tags',
|
||||
'ratings',
|
||||
'rating_avg',
|
||||
'rating_count',
|
||||
],
|
||||
day: [
|
||||
'id',
|
||||
'trip_id',
|
||||
'day_number',
|
||||
'date',
|
||||
'notes',
|
||||
'title',
|
||||
'default_transport_mode',
|
||||
'assignments',
|
||||
'notes_items',
|
||||
],
|
||||
reservation: [
|
||||
'id',
|
||||
'trip_id',
|
||||
'day_id',
|
||||
'end_day_id',
|
||||
'place_id',
|
||||
'assignment_id',
|
||||
'title',
|
||||
'accommodation_id',
|
||||
'reservation_time',
|
||||
'reservation_end_time',
|
||||
'location',
|
||||
'confirmation_number',
|
||||
'notes',
|
||||
'status',
|
||||
'type',
|
||||
'created_at',
|
||||
'metadata',
|
||||
'day_plan_position',
|
||||
'needs_review',
|
||||
'external_source',
|
||||
'external_id',
|
||||
'external_owner_user_id',
|
||||
'external_synced_at',
|
||||
'sync_enabled',
|
||||
'external_hash',
|
||||
'url',
|
||||
'ingest_state',
|
||||
'day_number',
|
||||
'place_name',
|
||||
'accommodation_place_id',
|
||||
'accommodation_name',
|
||||
'accommodation_start_day_id',
|
||||
'accommodation_end_day_id',
|
||||
'day_positions',
|
||||
'endpoints',
|
||||
'travelers',
|
||||
],
|
||||
packingItem: [
|
||||
'id',
|
||||
'trip_id',
|
||||
'name',
|
||||
'checked',
|
||||
'category',
|
||||
'sort_order',
|
||||
'created_at',
|
||||
'weight_grams',
|
||||
'bag_id',
|
||||
'quantity',
|
||||
'updated_at',
|
||||
'is_private',
|
||||
'owner_id',
|
||||
'packed_quantity',
|
||||
'owner_username',
|
||||
'recipients',
|
||||
'contributors',
|
||||
],
|
||||
tripFile: [
|
||||
'id',
|
||||
'trip_id',
|
||||
'place_id',
|
||||
'reservation_id',
|
||||
'filename',
|
||||
'original_name',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'description',
|
||||
'created_at',
|
||||
'note_id',
|
||||
'uploaded_by',
|
||||
'starred',
|
||||
'deleted_at',
|
||||
'message_id',
|
||||
'reservation_title',
|
||||
'uploaded_by_name',
|
||||
'uploaded_by_avatar',
|
||||
'url',
|
||||
'linked_reservation_ids',
|
||||
'linked_place_ids',
|
||||
'linked_budget_item_ids',
|
||||
],
|
||||
budgetItem: [
|
||||
'id',
|
||||
'trip_id',
|
||||
'category',
|
||||
'name',
|
||||
'total_price',
|
||||
'persons',
|
||||
'days',
|
||||
'note',
|
||||
'sort_order',
|
||||
'created_at',
|
||||
'paid_by_user_id',
|
||||
'expense_date',
|
||||
'reservation_id',
|
||||
'currency',
|
||||
'exchange_rate',
|
||||
'ticket_json',
|
||||
'place_id',
|
||||
'members',
|
||||
'payers',
|
||||
'receipts',
|
||||
],
|
||||
assignment: [
|
||||
'id',
|
||||
'day_id',
|
||||
'place_id',
|
||||
'order_index',
|
||||
'notes',
|
||||
'assignment_time',
|
||||
'assignment_end_time',
|
||||
'end_day',
|
||||
'leg_transport_mode',
|
||||
'incoming_leg_transport_mode',
|
||||
'route_excluded',
|
||||
'accommodation_id',
|
||||
'created_at',
|
||||
'participants',
|
||||
'tour_place_id',
|
||||
'tour_route_geometry',
|
||||
'place',
|
||||
],
|
||||
user: [
|
||||
'id',
|
||||
'username',
|
||||
'display_name',
|
||||
'avatar',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (`trip`, or
|
||||
* `trip[]` for a list), `host` for a value the host builds itself or data the
|
||||
* plugin owns, or `readModel` for a domain read model passed on as the app's own
|
||||
* REST route returns it.
|
||||
*/
|
||||
export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
|
||||
'db.exec': 'host',
|
||||
'db.query': 'host',
|
||||
'db.migrate': 'host',
|
||||
'db.tx': 'host',
|
||||
'trips.getById': 'trip',
|
||||
'trips.getPlaces': 'place[]',
|
||||
'trips.getReservations': 'reservation[]',
|
||||
'trips.getDays': 'day[]',
|
||||
'trips.getAccommodations': 'readModel',
|
||||
'trips.listMine': 'trip[]',
|
||||
'reservations.listMine': 'reservation[]',
|
||||
'reservations.create': 'reservation',
|
||||
'reservations.update': 'reservation',
|
||||
'reservations.delete': 'host',
|
||||
'accommodations.create': 'readModel',
|
||||
'accommodations.update': 'readModel',
|
||||
'accommodations.delete': 'host',
|
||||
'packing.list': 'packingItem[]',
|
||||
'packing.create': 'packingItem',
|
||||
'packing.update': 'packingItem',
|
||||
'packing.delete': 'host',
|
||||
'packing.listBags': 'readModel',
|
||||
'packing.createBag': 'readModel',
|
||||
'packing.updateBag': 'readModel',
|
||||
'packing.deleteBag': 'host',
|
||||
'packing.setBagMembers': 'readModel',
|
||||
'files.list': 'tripFile[]',
|
||||
'files.getContent': 'host',
|
||||
'files.create': 'tripFile',
|
||||
'files.createLink': 'readModel',
|
||||
'files.update': 'tripFile',
|
||||
'files.softDelete': 'host',
|
||||
'collab.listNotes': 'readModel',
|
||||
'collab.listPolls': 'readModel',
|
||||
'collab.listMessages': 'readModel',
|
||||
'collab.createNote': 'readModel',
|
||||
'collab.createPoll': 'readModel',
|
||||
'collab.votePoll': 'readModel',
|
||||
'collab.createMessage': 'readModel',
|
||||
'trips.addMember': 'host',
|
||||
'trips.removeMember': 'host',
|
||||
'trips.create': 'trip',
|
||||
'journal.listMine': 'readModel',
|
||||
'journal.getEntries': 'readModel',
|
||||
'atlas.visited': 'readModel',
|
||||
'atlas.bucketList': 'readModel',
|
||||
'rates.get': 'host',
|
||||
'vacay.mine': 'readModel',
|
||||
'daynotes.list': 'readModel',
|
||||
'daynotes.create': 'readModel',
|
||||
'daynotes.update': 'readModel',
|
||||
'daynotes.delete': 'host',
|
||||
'collections.listMine': 'readModel',
|
||||
'collections.get': 'readModel',
|
||||
'collections.create': 'readModel',
|
||||
'collections.update': 'readModel',
|
||||
'collections.savePlace': 'readModel',
|
||||
'collections.copyToTrip': 'readModel',
|
||||
'collections.deletePlace': 'host',
|
||||
'atlas.markCountry': 'host',
|
||||
'atlas.unmarkCountry': 'host',
|
||||
'atlas.markRegion': 'host',
|
||||
'atlas.unmarkRegion': 'host',
|
||||
'atlas.createBucketItem': 'readModel',
|
||||
'atlas.deleteBucketItem': 'host',
|
||||
'vacay.toggleEntry': 'readModel',
|
||||
'vacay.toggleCompanyHoliday': 'readModel',
|
||||
'journal.createEntry': 'readModel',
|
||||
'journal.addEntryPhoto': 'readModel',
|
||||
'journal.updateEntry': 'readModel',
|
||||
'journal.deleteEntry': 'host',
|
||||
'journal.createJourney': 'readModel',
|
||||
'journal.deleteJourney': 'host',
|
||||
'weather.get': 'host',
|
||||
'categories.list': 'readModel',
|
||||
'tags.list': 'readModel',
|
||||
'tags.create': 'readModel',
|
||||
'tags.update': 'readModel',
|
||||
'tags.delete': 'host',
|
||||
'trips.members': 'user[]',
|
||||
'todos.list': 'readModel',
|
||||
'todos.create': 'readModel',
|
||||
'todos.update': 'readModel',
|
||||
'todos.delete': 'host',
|
||||
'costs.getByTrip': 'budgetItem[]',
|
||||
'costs.listMine': 'budgetItem[]',
|
||||
'costs.create': 'budgetItem',
|
||||
'costs.update': 'budgetItem',
|
||||
'costs.delete': 'host',
|
||||
'places.create': 'place',
|
||||
'places.update': 'place',
|
||||
'places.delete': 'host',
|
||||
'days.create': 'day',
|
||||
'days.update': 'day',
|
||||
'days.delete': 'host',
|
||||
'itinerary.assign': 'assignment',
|
||||
'itinerary.unassign': 'host',
|
||||
'trips.update': 'trip',
|
||||
'meta.get': 'host',
|
||||
'meta.set': 'host',
|
||||
'meta.list': 'host',
|
||||
'meta.delete': 'host',
|
||||
'users.getById': 'user',
|
||||
'ws.broadcastToTrip': 'host',
|
||||
'ws.broadcastToUser': 'host',
|
||||
'notify.send': 'host',
|
||||
'ai.complete': 'host',
|
||||
'ai.extract': 'host',
|
||||
'oauth.getToken': 'host',
|
||||
'scheduler.set': 'host',
|
||||
'scheduler.cancel': 'host',
|
||||
'plugins.call': 'host',
|
||||
'events.emit': 'host',
|
||||
'settings.get': 'host',
|
||||
};
|
||||
|
||||
+35
-4
@@ -6,15 +6,25 @@
|
||||
* 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
|
||||
// because they mirror raw DB rows — and every shape keeps an index signature, so no
|
||||
// column is ever hidden from you.
|
||||
// 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; day_id?: number | null; category_id?: number | null; notes?: 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 }
|
||||
@@ -23,6 +33,25 @@ export interface BudgetItem { id: number; trip_id?: number; name?: string; total
|
||||
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:
|
||||
@@ -976,6 +1005,8 @@ export {
|
||||
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';
|
||||
|
||||
|
||||
@@ -136,12 +136,11 @@ const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
// Addon-id format (lowercase slug, underscores allowed e.g. `llm_parsing`).
|
||||
const ADDON_ID_RE = /^[a-z][a-z0-9_]{1,39}$/;
|
||||
// Mirror of the server's ADDON_IDS (server/src/addons.ts). Kept in sync so
|
||||
// `trek-plugin validate` can WARN on an addon id TREK doesn't know — never a hard
|
||||
// The server's ADDON_IDS (server/src/addons.ts), generated into host-facts.ts by
|
||||
// gen:plugin-facts so it cannot drift again (dawarich, roadtrip and tours had). Used
|
||||
// so `trek-plugin validate` can WARN on an addon id TREK doesn't know, never a hard
|
||||
// error (a plugin built for a newer TREK may reference an addon this SDK predates).
|
||||
export const KNOWN_ADDONS = [
|
||||
'mcp', 'packing', 'budget', 'documents', 'vacay', 'atlas', 'collab', 'journey', 'airtrail', 'llm_parsing', 'collections',
|
||||
];
|
||||
export { KNOWN_ADDONS } from './generated/host-facts.js';
|
||||
// An outbound host: exact hostname (single-label sibling or dotted FQDN) or a
|
||||
// `*.`-wildcard with a multi-label suffix. No `*`, no `*.`, no whole-TLD `*.com`,
|
||||
// no spaces (mirrors the server manifest validator).
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The output contract as the SDK publishes it. The lists are generated from the host's
|
||||
* protocol/output-contract.ts (server check:plugin-facts keeps them in sync) and the
|
||||
* entity interfaces in src/index.ts are type-checked against them; these cases pin the
|
||||
* runtime side: what the exports hold and that they cover the method vocabulary.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PLUGIN_ENTITY_FIELDS, PLUGIN_METHOD_RESULT } from '../src/index.js';
|
||||
import { KNOWN_ADDONS } from '../src/manifest.js';
|
||||
import { KNOWN_METHODS } from '../src/generated/host-facts.js';
|
||||
|
||||
describe('PLUGIN_ENTITY_FIELDS', () => {
|
||||
it('names the fields plugins rely on and never a credential', () => {
|
||||
expect(PLUGIN_ENTITY_FIELDS.trip).toEqual(expect.arrayContaining(['id', 'user_id', 'title', 'start_date', 'end_date', 'currency']));
|
||||
expect(PLUGIN_ENTITY_FIELDS.trip).not.toContain('feed_token');
|
||||
expect(PLUGIN_ENTITY_FIELDS.user).toEqual(['id', 'username', 'display_name', 'avatar']);
|
||||
expect(PLUGIN_ENTITY_FIELDS.place).toEqual(expect.arrayContaining(['trip_id', 'name', 'lat', 'lng', 'category_id', 'notes']));
|
||||
// A place has no day of its own: Place.day_id is deprecated, never delivered.
|
||||
expect(PLUGIN_ENTITY_FIELDS.place).not.toContain('day_id');
|
||||
});
|
||||
|
||||
it('lists every field once', () => {
|
||||
for (const fields of Object.values(PLUGIN_ENTITY_FIELDS)) {
|
||||
expect(new Set(fields).size).toBe(fields.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLUGIN_METHOD_RESULT', () => {
|
||||
it('describes every wire method plus the three unconditional ones', () => {
|
||||
for (const method of KNOWN_METHODS) expect(PLUGIN_METHOD_RESULT[method]).toBeDefined();
|
||||
for (const method of ['plugins.call', 'events.emit', 'settings.get']) expect(PLUGIN_METHOD_RESULT[method]).toBeDefined();
|
||||
expect(Object.keys(PLUGIN_METHOD_RESULT)).toHaveLength(KNOWN_METHODS.length + 3);
|
||||
});
|
||||
|
||||
it('names only entities that have a field list, or host or readModel', () => {
|
||||
for (const result of Object.values(PLUGIN_METHOD_RESULT)) {
|
||||
const entity = result.replace(/\[\]$/, '');
|
||||
expect(entity in PLUGIN_ENTITY_FIELDS || entity === 'host' || entity === 'readModel').toBe(true);
|
||||
}
|
||||
expect(PLUGIN_METHOD_RESULT['trips.getById']).toBe('trip');
|
||||
expect(PLUGIN_METHOD_RESULT['trips.getPlaces']).toBe('place[]');
|
||||
expect(PLUGIN_METHOD_RESULT['db.query']).toBe('host');
|
||||
});
|
||||
});
|
||||
|
||||
describe('KNOWN_ADDONS', () => {
|
||||
it('carries the addons the hand-kept copy had missed', () => {
|
||||
expect(KNOWN_ADDONS).toEqual(expect.arrayContaining(['dawarich', 'roadtrip', 'tours', 'budget', 'collections']));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`c
|
||||
- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
|
||||
- **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `<domain>.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
|
||||
- **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator**: it bypasses that gate. Every tick first takes the job's row in `scheduler_leases` (`SchedulerLeasesRepository.acquire` in one `UnitOfWork` transaction, where a conditional UPDATE decides the race), renews it while it runs and keeps it for a short settle window afterwards, so two processes on one database never run the same tick; a job needs nothing of its own for this. Claims on individual rows (the reminder jobs' `claimReminder`) stay where they are: the lease decides who runs a tick, the claim what a tick may send.
|
||||
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
|
||||
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity read (`trips.getById`, `costs.listMine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, so a new column on `trips`, `places`, `reservations` and the other whole-row tables fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` is generated from it). `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
|
||||
- **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. Where the data lives (`data/`, `uploads/`, backups, logs, key files, the DB file) is resolved in one place, `src/app-config/data-paths.ts`; a provider that reads one injects `DataPathsService`, plain helper modules shared with code outside the container call `resolveDataPaths()` (the list is in `src/app-config/README.md`), and nothing anchors a data path on its own `__dirname` or on `process.cwd()`.
|
||||
- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only holds the connection and applies pragmas. It does not build a schema, and importing it opens nothing (the first reader does). The connection's lifecycle belongs to the `DatabaseLifecycle` provider (`nest/database/database-lifecycle.service.ts`): `buildApp()` opens it through `open()` (which binds the ORM to later swaps and runs the schema bootstrap below), the shutdown closes it through `close()`, and a restore or the demo reset swaps the file only through the database backup port, `DATABASE_BACKUP` (`nest/database/database-backup.interface.ts`; the SQLite implementation is `nest/backup/sqlite-database-backup.ts`: `VACUUM INTO` snapshot, close, atomic file swap, `reopen()`). The port works on `DatabaseLifecycle.file`, the one resolved path (`TREK_DB_FILE` or `data/travel.db`), and a snapshot that fails fails the backup rather than archiving the live file. `MaintenanceRepository` is provided only by `MaintenanceModule`'s factory and injected everywhere else; no consumer constructs it. A provider that has to follow every reopen (the demo reset job saves the first demo baseline after a restore's re-bootstrap seeded the example trips) registers with `DatabaseLifecycle.onReopened()`, which runs after the reopen's schema bootstrap; a listener contains its own failures, since one that throws fails the reopen. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. Before a boot applies pending migrations to a file database that holds anything, `db/pre-migrate-snapshot.ts` writes a `VACUUM INTO` copy next to it (`pre-migrate-<schema>-<time>.db`, `<schema>` being `legacy-<schema_version>` or the last recorded migration's timestamp; the newest `TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP` stay) and the newer-database refusal names the matching copy; a copy that cannot be written refuses the boot unless `TREK_DB_PRE_MIGRATE_SNAPSHOT=false`. It is off under `NODE_ENV=test` unless a suite sets that variable to true, and a restore's re-bootstrap skips it (the archive is the copy). After the migrator, `db/email-case-index.ts` creates the case-insensitive unique index on `users.email` that its migration had to defer on an install holding two accounts whose emails differ only in case (flag `email_case_index_pending`). `db/orm-driver.ts` binds the ORM to that same connection instead of opening a second one — with `:memory:` under test, a second connection is a second, empty database. Kysely statements (`this.kysely<...>()`) type against `DB` from `db/kysely/db.ts`, narrowed with `Pick<DB, 'trips' | 'days'>`; those files are generated from the migrated schema by `scripts/generate-db-types.ts`, so a migration that adds or changes a column is followed by `npm run gen:db-types`, never by a hand-written table interface. Every read and write goes through a repository in `db/repositories/` (each extends the shared `TrekRepository` base in `db/repositories/_shared/`, which validates the request context before delegating to MikroORM) and, for multi-statement writes, `UnitOfWork.transactional(...)` (`nest/database/unit-of-work.ts`, provided by the global `OrmModule`) — there is no `DatabaseService`/`DatabaseModule` to inject any more. Entrypoints outside an HTTP request — an MCP tool call, a plugin RPC dispatch, a WebSocket message, a cron job, boot-time seeding, a backup restore — open one explicitly with `withRequestContext()` (`nest/database/request-context.ts`), since only the pathless `mikroOrmRequestContext` middleware `buildApp()` mounts (with `@mikro-orm/nestjs`'s own `registerRequestContext` turned off in `app.module.ts`, UPLOADS-P16) forks an `EntityManager` per HTTP request. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker, opened from a snapshot `tests/global-setup.ts` migrates once per run; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Regenerates every derived copy of the plugin permission facts from the single source,
|
||||
* server/src/nest/plugins/protocol/envelope.ts.
|
||||
* server/src/nest/plugins/protocol/envelope.ts, plus the output contract
|
||||
* (protocol/output-contract.ts) and the addon ids (src/addons.ts) the SDK publishes.
|
||||
*
|
||||
* node --import tsx server/scripts/gen-plugin-facts.ts # write
|
||||
* node --import tsx server/scripts/gen-plugin-facts.ts --check # exit 1 on drift
|
||||
@@ -22,6 +23,10 @@ import {
|
||||
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
|
||||
} from '../src/nest/plugins/protocol/envelope';
|
||||
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
|
||||
import {
|
||||
PLUGIN_ENTITY_CONTRACT, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName, type PluginMethodOutput,
|
||||
} from '../src/nest/plugins/protocol/output-contract';
|
||||
import { ADDON_IDS } from '../src/addons';
|
||||
// A relative import of the shared SOURCE, not @trek/shared: the plugin-facts CI job
|
||||
// installs only the server, so shared's dist does not exist there. The file has no
|
||||
// imports of its own for exactly this reason.
|
||||
@@ -34,6 +39,7 @@ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const HEADER = [
|
||||
'// GENERATED — do not edit by hand.',
|
||||
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
|
||||
'// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts',
|
||||
'// + shared/src/plugins/plugin-poi-facts.ts',
|
||||
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
|
||||
'',
|
||||
@@ -53,6 +59,19 @@ const pairs = (o: Readonly<Record<string, string>>) =>
|
||||
*/
|
||||
const EVENT_FAMILIES = [...new Set([...Object.keys(ENTITY_ID_KEYS), ...Object.keys(SNAPSHOT_GRANT)])].sort();
|
||||
|
||||
/** `'trip'`, `'trip[]'`, `'host'` or `'readModel'`: what a method's result is. */
|
||||
const describeOutput = (output: PluginMethodOutput): string =>
|
||||
output.kind === 'entity' ? `${output.entity}${output.many ? '[]' : ''}` : output.kind;
|
||||
|
||||
const fieldList = (fields: readonly string[]) => fields.map((f) => ` '${f}',`).join('\n');
|
||||
const ENTITY_FIELDS = (Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[])
|
||||
.map((entity) => ` ${entity}: [\n${fieldList(pluginEntityFields(entity))}\n ],`)
|
||||
.join('\n');
|
||||
|
||||
const METHOD_RESULT = Object.fromEntries(
|
||||
Object.entries(PLUGIN_METHOD_OUTPUT).map(([method, output]) => [method, describeOutput(output)]),
|
||||
);
|
||||
|
||||
const SDK_FACTS = `${HEADER}
|
||||
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
${pairs(HOOK_PERMISSION)}
|
||||
@@ -100,6 +119,34 @@ ${list(PLUGIN_POI_ICONS)}
|
||||
|
||||
export const POI_CATEGORY_MAX = ${PLUGIN_POI_MAX_CATEGORIES};
|
||||
export const POI_CATEGORY_LABEL_MAX = ${PLUGIN_POI_LABEL_MAX};
|
||||
|
||||
/**
|
||||
* The addon ids a manifest's \`requiredAddons\` may name: TREK's ADDON_IDS.
|
||||
*/
|
||||
export const KNOWN_ADDONS: string[] = [
|
||||
${list(Object.values(ADDON_IDS))}
|
||||
];
|
||||
|
||||
/**
|
||||
* The fields each entity result carries, and nothing else: the row's published
|
||||
* columns, then the keys the host adds (joined names, counts, hydrated children).
|
||||
* A column TREK adds later is not delivered until it is listed here. A literal,
|
||||
* unlike the wide tables above, because it is new (nothing published depends on a
|
||||
* wider type) and the SDK's entity interfaces are type-checked against it.
|
||||
*/
|
||||
export const PLUGIN_ENTITY_FIELDS = {
|
||||
${ENTITY_FIELDS}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (\`trip\`, or
|
||||
* \`trip[]\` for a list), \`host\` for a value the host builds itself or data the
|
||||
* plugin owns, or \`readModel\` for a domain read model passed on as the app's own
|
||||
* REST route returns it.
|
||||
*/
|
||||
export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
|
||||
${pairs(METHOD_RESULT)}
|
||||
};
|
||||
`;
|
||||
|
||||
const SHARED_FACTS = `${HEADER}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { PluginDataDb } from './plugin-data.service';
|
||||
import { auditResource, isAuditable } from './plugin-audit';
|
||||
import { BadParams, ForbiddenResource } from './rpc-errors';
|
||||
import { shapePluginOutput } from '../protocol/output-contract';
|
||||
import type { PluginRpcRegistry } from './rpc-kit/registry';
|
||||
|
||||
// Both used to be declared here. They now live in rpc-errors.ts so decorated
|
||||
@@ -28,7 +29,8 @@ export { BadParams, ForbiddenResource };
|
||||
* a `@PluginMethod` / `@PluginOpenMethod` on a `@PluginController()` provider in its
|
||||
* own domain, and the registry binds the granted subset into the map below. What is
|
||||
* left is the part that was never domain-specific: build the map, dispatch into it,
|
||||
* audit the call, and map a thrown error onto a wire code.
|
||||
* apply the output contract to the result, audit the call, and map a thrown error
|
||||
* onto a wire code.
|
||||
*
|
||||
* Runs in the HOST (parent) process.
|
||||
*/
|
||||
@@ -129,7 +131,10 @@ export class PluginRpcHost {
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await handler(params, actingUserId);
|
||||
// The output contract (protocol/output-contract.ts): every result leaves through
|
||||
// here, so an entity read hands the plugin its published fields only, whatever
|
||||
// the query behind it selected. Other methods' results pass through unchanged.
|
||||
const result = shapePluginOutput(req.method, await handler(params, actingUserId));
|
||||
return { k: 'res', id: req.id, ok: true, result };
|
||||
} catch (e) {
|
||||
if (e instanceof BadParams) return this.err(req.id, 'BAD_PARAMS', e.message);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* What a plugin receives back from the host, per RPC method.
|
||||
*
|
||||
* Before this table the entity reads handed plugins whatever the 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 a
|
||||
* column rename became an unversioned break of a published API. The entity results
|
||||
* now go through an allowlist instead: the fields below are exactly what plugins
|
||||
* received when the contract was drawn, minus the credentials, and a new column stays
|
||||
* on the host until someone adds it here on purpose.
|
||||
*
|
||||
* The plugin SDK publishes the same lists (`PLUGIN_ENTITY_FIELDS`), generated from
|
||||
* this file by `server/scripts/gen-plugin-facts.ts`, and type-checks its own entity
|
||||
* interfaces against them. `tests/unit/plugins/output-contract.schema.test.ts` holds
|
||||
* the column lists to the migrated schema, so a new column fails CI until it is
|
||||
* classified as published or withheld.
|
||||
*
|
||||
* Pure data and one pure function, like envelope.ts: no imports with side effects.
|
||||
*/
|
||||
import type { KnownMethod, UnconditionalMethod } from './envelope';
|
||||
|
||||
export interface PluginEntityContract {
|
||||
/** The table the rows are read from. */
|
||||
readonly table: string;
|
||||
/** The table's columns a plugin receives, in the order the row carries them. */
|
||||
readonly columns: readonly string[];
|
||||
/** Columns a plugin never receives: credentials, whichever read path carries them. */
|
||||
readonly withheld: readonly string[];
|
||||
/**
|
||||
* True when the reads select the whole row (`t.*`), so every column of `table` has
|
||||
* to be listed in `columns` or `withheld` and the schema test enforces it. False
|
||||
* when every read names its columns, which keeps the rest out by construction.
|
||||
*/
|
||||
readonly wholeRow: boolean;
|
||||
/** Keys the host adds on top of the row: joined names, counts, hydrated children. */
|
||||
readonly derived: readonly string[];
|
||||
}
|
||||
|
||||
export const PLUGIN_ENTITY_CONTRACT = {
|
||||
trip: {
|
||||
table: 'trips',
|
||||
columns: [
|
||||
'id', 'user_id', 'title', 'description', 'start_date', 'end_date', 'currency', 'cover_image',
|
||||
'is_archived', 'reminder_days', 'created_at', 'updated_at', 'reminder_sent_for',
|
||||
],
|
||||
// The sole credential of the anonymous calendar feed.
|
||||
withheld: ['feed_token'],
|
||||
wholeRow: true,
|
||||
derived: ['day_count', 'place_count', 'is_owner', 'owner_username', 'shared_count'],
|
||||
},
|
||||
place: {
|
||||
table: 'places',
|
||||
columns: [
|
||||
'id', 'trip_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency',
|
||||
'reservation_status', 'reservation_notes', 'reservation_datetime', 'place_time', 'end_time',
|
||||
'duration_minutes', 'notes', 'image_url', 'google_place_id', 'google_ftid', 'website', 'phone',
|
||||
'transport_mode', 'created_at', 'updated_at', 'osm_id', 'route_geometry', 'route_color', 'stop_type',
|
||||
'fill_percent', 'amap_poi_id', 'source', 'email', 'opening_hours',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [
|
||||
'category_name', 'category_color', 'category_icon', 'tour_place_id', 'category', 'tags', 'ratings',
|
||||
'rating_avg', 'rating_count',
|
||||
],
|
||||
},
|
||||
day: {
|
||||
table: 'days',
|
||||
columns: ['id', 'trip_id', 'day_number', 'date', 'notes', 'title', 'default_transport_mode'],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['assignments', 'notes_items'],
|
||||
},
|
||||
reservation: {
|
||||
table: 'reservations',
|
||||
columns: [
|
||||
'id', 'trip_id', 'day_id', 'end_day_id', 'place_id', 'assignment_id', 'title', 'accommodation_id',
|
||||
'reservation_time', 'reservation_end_time', 'location', 'confirmation_number', 'notes', 'status', 'type',
|
||||
'created_at', 'metadata', 'day_plan_position', 'needs_review', 'external_source', 'external_id',
|
||||
'external_owner_user_id', 'external_synced_at', 'sync_enabled', 'external_hash', 'url', 'ingest_state',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [
|
||||
'day_number', 'place_name', 'accommodation_place_id', 'accommodation_name', 'accommodation_start_day_id',
|
||||
'accommodation_end_day_id', 'day_positions', 'endpoints', 'travelers',
|
||||
],
|
||||
},
|
||||
packingItem: {
|
||||
table: 'packing_items',
|
||||
columns: [
|
||||
'id', 'trip_id', 'name', 'checked', 'category', 'sort_order', 'created_at', 'weight_grams', 'bag_id',
|
||||
'quantity', 'updated_at', 'is_private', 'owner_id', 'packed_quantity',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['owner_username', 'recipients', 'contributors'],
|
||||
},
|
||||
tripFile: {
|
||||
table: 'trip_files',
|
||||
columns: [
|
||||
'id', 'trip_id', 'place_id', 'reservation_id', 'filename', 'original_name', 'file_size', 'mime_type',
|
||||
'description', 'created_at', 'note_id', 'uploaded_by', 'starred', 'deleted_at', 'message_id',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: [
|
||||
'reservation_title', 'uploaded_by_name', 'uploaded_by_avatar', 'url', 'linked_reservation_ids',
|
||||
'linked_place_ids', 'linked_budget_item_ids',
|
||||
],
|
||||
},
|
||||
budgetItem: {
|
||||
table: 'budget_items',
|
||||
columns: [
|
||||
'id', 'trip_id', 'category', 'name', 'total_price', 'persons', 'days', 'note', 'sort_order', 'created_at',
|
||||
'paid_by_user_id', 'expense_date', 'reservation_id', 'currency', 'exchange_rate', 'ticket_json', 'place_id',
|
||||
],
|
||||
withheld: [],
|
||||
wholeRow: true,
|
||||
derived: ['members', 'payers', 'receipts'],
|
||||
},
|
||||
assignment: {
|
||||
table: 'day_assignments',
|
||||
columns: [
|
||||
'id', 'day_id', 'place_id', 'order_index', 'notes', 'assignment_time', 'assignment_end_time', 'end_day',
|
||||
'leg_transport_mode', 'incoming_leg_transport_mode', 'route_excluded', 'accommodation_id', 'created_at',
|
||||
],
|
||||
withheld: [],
|
||||
// The assignment read model names its fields one by one.
|
||||
wholeRow: false,
|
||||
derived: ['participants', 'tour_place_id', 'tour_route_geometry', 'place'],
|
||||
},
|
||||
user: {
|
||||
table: 'users',
|
||||
columns: ['id', 'username', 'display_name', 'avatar'],
|
||||
// Everything else on the users table is account data or a credential, and the
|
||||
// two reads that return users (users.getById, trips.members) name these four.
|
||||
withheld: [],
|
||||
wholeRow: false,
|
||||
derived: [],
|
||||
},
|
||||
} as const satisfies Record<string, PluginEntityContract>;
|
||||
|
||||
export type PluginEntityName = keyof typeof PLUGIN_ENTITY_CONTRACT;
|
||||
|
||||
/**
|
||||
* The output of one RPC method.
|
||||
*
|
||||
* - `entity`: rows of a published entity, allowlisted field by field.
|
||||
* - `host`: a value the host assembles itself (an acknowledgement, a model answer,
|
||||
* a token) or data the plugin owns (its own database, its metadata, a peer's
|
||||
* answer). There is no stored row behind it to leak.
|
||||
* - `readModel`: a domain read model shared with the REST route, passed through as
|
||||
* the app returns it. The SDK types these results as `unknown`; they are the next
|
||||
* candidates for an entity contract.
|
||||
*/
|
||||
export type PluginMethodOutput =
|
||||
| { readonly kind: 'entity'; readonly entity: PluginEntityName; readonly many: boolean }
|
||||
| { readonly kind: 'host' }
|
||||
| { readonly kind: 'readModel' };
|
||||
|
||||
const row = (entity: PluginEntityName): PluginMethodOutput => ({ kind: 'entity', entity, many: false });
|
||||
const rows = (entity: PluginEntityName): PluginMethodOutput => ({ kind: 'entity', entity, many: true });
|
||||
const HOST: PluginMethodOutput = { kind: 'host' };
|
||||
const READ_MODEL: PluginMethodOutput = { kind: 'readModel' };
|
||||
|
||||
/** One entry per wire method; the `satisfies` makes a method without an entry a compile error. */
|
||||
export const PLUGIN_METHOD_OUTPUT = {
|
||||
'db.exec': HOST,
|
||||
'db.query': HOST,
|
||||
'db.migrate': HOST,
|
||||
'db.tx': HOST,
|
||||
'trips.getById': row('trip'),
|
||||
'trips.getPlaces': rows('place'),
|
||||
'trips.getReservations': rows('reservation'),
|
||||
'trips.getDays': rows('day'),
|
||||
'trips.getAccommodations': READ_MODEL,
|
||||
'trips.listMine': rows('trip'),
|
||||
'reservations.listMine': rows('reservation'),
|
||||
'reservations.create': row('reservation'),
|
||||
'reservations.update': row('reservation'),
|
||||
'reservations.delete': HOST,
|
||||
'accommodations.create': READ_MODEL,
|
||||
'accommodations.update': READ_MODEL,
|
||||
'accommodations.delete': HOST,
|
||||
'packing.list': rows('packingItem'),
|
||||
'packing.create': row('packingItem'),
|
||||
'packing.update': row('packingItem'),
|
||||
'packing.delete': HOST,
|
||||
'packing.listBags': READ_MODEL,
|
||||
'packing.createBag': READ_MODEL,
|
||||
'packing.updateBag': READ_MODEL,
|
||||
'packing.deleteBag': HOST,
|
||||
'packing.setBagMembers': READ_MODEL,
|
||||
'files.list': rows('tripFile'),
|
||||
'files.getContent': HOST,
|
||||
'files.create': row('tripFile'),
|
||||
'files.createLink': READ_MODEL,
|
||||
'files.update': row('tripFile'),
|
||||
'files.softDelete': HOST,
|
||||
'collab.listNotes': READ_MODEL,
|
||||
'collab.listPolls': READ_MODEL,
|
||||
'collab.listMessages': READ_MODEL,
|
||||
'collab.createNote': READ_MODEL,
|
||||
'collab.createPoll': READ_MODEL,
|
||||
'collab.votePoll': READ_MODEL,
|
||||
'collab.createMessage': READ_MODEL,
|
||||
'trips.addMember': HOST,
|
||||
'trips.removeMember': HOST,
|
||||
'trips.create': row('trip'),
|
||||
'journal.listMine': READ_MODEL,
|
||||
'journal.getEntries': READ_MODEL,
|
||||
'atlas.visited': READ_MODEL,
|
||||
'atlas.bucketList': READ_MODEL,
|
||||
'rates.get': HOST,
|
||||
'vacay.mine': READ_MODEL,
|
||||
'daynotes.list': READ_MODEL,
|
||||
'daynotes.create': READ_MODEL,
|
||||
'daynotes.update': READ_MODEL,
|
||||
'daynotes.delete': HOST,
|
||||
'collections.listMine': READ_MODEL,
|
||||
'collections.get': READ_MODEL,
|
||||
'collections.create': READ_MODEL,
|
||||
'collections.update': READ_MODEL,
|
||||
'collections.savePlace': READ_MODEL,
|
||||
'collections.copyToTrip': READ_MODEL,
|
||||
'collections.deletePlace': HOST,
|
||||
'atlas.markCountry': HOST,
|
||||
'atlas.unmarkCountry': HOST,
|
||||
'atlas.markRegion': HOST,
|
||||
'atlas.unmarkRegion': HOST,
|
||||
'atlas.createBucketItem': READ_MODEL,
|
||||
'atlas.deleteBucketItem': HOST,
|
||||
'vacay.toggleEntry': READ_MODEL,
|
||||
'vacay.toggleCompanyHoliday': READ_MODEL,
|
||||
'journal.createEntry': READ_MODEL,
|
||||
'journal.addEntryPhoto': READ_MODEL,
|
||||
'journal.updateEntry': READ_MODEL,
|
||||
'journal.deleteEntry': HOST,
|
||||
'journal.createJourney': READ_MODEL,
|
||||
'journal.deleteJourney': HOST,
|
||||
'weather.get': HOST,
|
||||
'categories.list': READ_MODEL,
|
||||
'tags.list': READ_MODEL,
|
||||
'tags.create': READ_MODEL,
|
||||
'tags.update': READ_MODEL,
|
||||
'tags.delete': HOST,
|
||||
'trips.members': rows('user'),
|
||||
'todos.list': READ_MODEL,
|
||||
'todos.create': READ_MODEL,
|
||||
'todos.update': READ_MODEL,
|
||||
'todos.delete': HOST,
|
||||
'costs.getByTrip': rows('budgetItem'),
|
||||
'costs.listMine': rows('budgetItem'),
|
||||
'costs.create': row('budgetItem'),
|
||||
'costs.update': row('budgetItem'),
|
||||
'costs.delete': HOST,
|
||||
'places.create': row('place'),
|
||||
'places.update': row('place'),
|
||||
'places.delete': HOST,
|
||||
'days.create': row('day'),
|
||||
'days.update': row('day'),
|
||||
'days.delete': HOST,
|
||||
'itinerary.assign': row('assignment'),
|
||||
'itinerary.unassign': HOST,
|
||||
'trips.update': row('trip'),
|
||||
'meta.get': HOST,
|
||||
'meta.set': HOST,
|
||||
'meta.list': HOST,
|
||||
'meta.delete': HOST,
|
||||
'users.getById': row('user'),
|
||||
'ws.broadcastToTrip': HOST,
|
||||
'ws.broadcastToUser': HOST,
|
||||
'notify.send': HOST,
|
||||
'ai.complete': HOST,
|
||||
'ai.extract': HOST,
|
||||
'oauth.getToken': HOST,
|
||||
'scheduler.set': HOST,
|
||||
'scheduler.cancel': HOST,
|
||||
'plugins.call': HOST,
|
||||
'events.emit': HOST,
|
||||
'settings.get': HOST,
|
||||
} as const satisfies Record<KnownMethod | UnconditionalMethod, PluginMethodOutput>;
|
||||
|
||||
/** The fields a plugin receives for `entity`: its published columns, then the derived keys. */
|
||||
export function pluginEntityFields(entity: PluginEntityName): readonly string[] {
|
||||
const contract: PluginEntityContract = PLUGIN_ENTITY_CONTRACT[entity];
|
||||
return [...contract.columns, ...contract.derived];
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of `row` holding only the published fields, in the row's own key order.
|
||||
* Values are passed on untouched. Anything that is not a plain row (null, undefined,
|
||||
* a primitive) passes through as it is, so "not found" keeps its wire form.
|
||||
*/
|
||||
function pickFields(value: unknown, fields: ReadonlySet<string>): unknown {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, field] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (fields.has(key)) out[key] = field;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function outputOf(method: string): PluginMethodOutput | undefined {
|
||||
return (PLUGIN_METHOD_OUTPUT as Readonly<Record<string, PluginMethodOutput | undefined>>)[method];
|
||||
}
|
||||
|
||||
/** Whether `method` returns published entity rows, i.e. whether its result is shaped. */
|
||||
export function returnsEntity(method: string): boolean {
|
||||
return outputOf(method)?.kind === 'entity';
|
||||
}
|
||||
|
||||
/** Applies the method's output contract to a handler's result. Unlisted methods pass through. */
|
||||
export function shapePluginOutput(method: string, result: unknown): unknown {
|
||||
const output = outputOf(method);
|
||||
if (output?.kind !== 'entity') return result;
|
||||
const fields: ReadonlySet<string> = new Set(pluginEntityFields(output.entity));
|
||||
if (output.many) return Array.isArray(result) ? result.map((item) => pickFields(item, fields)) : result;
|
||||
return pickFields(result, fields);
|
||||
}
|
||||
@@ -155,6 +155,17 @@ import { MaintenanceRepository } from '../../src/db/repositories/MaintenanceRepo
|
||||
* container would discover, handed to the host factory as a registry.
|
||||
*/
|
||||
export async function createPluginRpcHostFactory(db: Database.Database): Promise<PluginRpcHostFactory> {
|
||||
return (await createPluginRpcHostParts(db)).factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host factory together with the controller instances behind its registry, for a
|
||||
* test that needs to call a handler directly as well as through the router (the output
|
||||
* contract compares the two).
|
||||
*/
|
||||
export async function createPluginRpcHostParts(
|
||||
db: Database.Database,
|
||||
): Promise<{ factory: PluginRpcHostFactory; controllers: object[] }> {
|
||||
const generalStorage = makeStorageFixture('').storage;
|
||||
const appSettings = (await sharedTestOrm(db)).repo(AppSettings);
|
||||
const usersRepo = (await sharedTestOrm(db)).repo(Users);
|
||||
@@ -311,7 +322,7 @@ export async function createPluginRpcHostFactory(db: Database.Database): Promise
|
||||
// TripsRepository.findAccessible directly, in the same constructor slot.
|
||||
const guards = new PluginGuards(await createTestTripsRepo(db), permissions, addons, usersRepo);
|
||||
|
||||
const registry = createTestPluginRegistry([
|
||||
const controllers: object[] = [
|
||||
new TagsRpc(new TagsService(await createTestTagsRepo(db))),
|
||||
new CategoriesRpc(new CategoriesService(await createTestCategoriesRepo(db))),
|
||||
new WeatherRpc(new WeatherService()),
|
||||
@@ -358,8 +369,9 @@ export async function createPluginRpcHostFactory(db: Database.Database): Promise
|
||||
usersRepo, await createTestTripsRepo(db), pluginOrm.repo(PluginScheduledTasks),
|
||||
),
|
||||
new PluginHooks(undefined as never),
|
||||
]);
|
||||
return new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
|
||||
];
|
||||
const registry = createTestPluginRegistry(controllers);
|
||||
return { factory: new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService), controllers };
|
||||
}
|
||||
|
||||
/** A PluginRuntimeService constructed the way Nest would: with a real host factory. */
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* The plugin output contract (protocol/output-contract.ts) against the real thing.
|
||||
*
|
||||
* Two questions, both answered on the migrated schema and the real domain services:
|
||||
*
|
||||
* - Does every column of a whole-row table have a decision? A migration that adds a
|
||||
* column to `trips` fails here until the column is listed as published or withheld,
|
||||
* which is the point: a new column no longer reaches plugins by default, and nobody
|
||||
* forgets to decide.
|
||||
* - Does the contract drop anything a plugin received before it existed? Each entity
|
||||
* method is called once directly on its handler and its result is put through the
|
||||
* contract; the only keys allowed to disappear are the withheld credentials. A new
|
||||
* key a read model starts carrying fails here the same way a new column does.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createPluginRpcHostParts } from '../../helpers/plugin-host';
|
||||
import { addTripMember, createBudgetItem, createDayAssignment, createPackingItem, createPlace, createReservation, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
||||
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
|
||||
import type { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
|
||||
import { KNOWN_PERMISSIONS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
|
||||
import {
|
||||
PLUGIN_ENTITY_CONTRACT,
|
||||
PLUGIN_METHOD_OUTPUT,
|
||||
shapePluginOutput,
|
||||
type PluginEntityContract,
|
||||
type PluginEntityName,
|
||||
} from '../../../src/nest/plugins/protocol/output-contract';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
const ENTITIES = Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[];
|
||||
const contractOf = (entity: PluginEntityName): PluginEntityContract => PLUGIN_ENTITY_CONTRACT[entity];
|
||||
|
||||
const columnsOf = (table: string): string[] =>
|
||||
(testDb.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name);
|
||||
|
||||
describe('the output contract against the migrated schema', () => {
|
||||
it.each(ENTITIES)('OUTCONTRACT-SCHEMA-001 %s names only columns its table has', (entity) => {
|
||||
const contract = contractOf(entity);
|
||||
const columns = columnsOf(contract.table);
|
||||
expect(columns.length).toBeGreaterThan(0);
|
||||
expect(contract.columns.filter((c) => !columns.includes(c))).toEqual([]);
|
||||
expect(contract.withheld.filter((c) => !columns.includes(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(ENTITIES.filter((e) => contractOf(e).wholeRow))(
|
||||
'OUTCONTRACT-SCHEMA-002 every column of %s is published or withheld',
|
||||
(entity) => {
|
||||
const contract = contractOf(entity);
|
||||
const decided = new Set([...contract.columns, ...contract.withheld]);
|
||||
// A failure names the new column: add it to `columns` to publish it to plugins,
|
||||
// or to `withheld` if it is a credential.
|
||||
expect(columnsOf(contract.table).filter((c) => !decided.has(c))).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('the output contract drops only the withheld fields', () => {
|
||||
let host: PluginRpcHost;
|
||||
let callHandler: (method: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
let ownerId: number;
|
||||
let memberId: number;
|
||||
let tripId: number;
|
||||
let dayId: number;
|
||||
let placeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
ownerId = owner.id;
|
||||
memberId = member.id;
|
||||
const trip = createTrip(testDb, owner.id, { start_date: '2026-05-01', end_date: '2026-05-03' });
|
||||
tripId = trip.id;
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
// A real credential in the row, so "withheld" is proven rather than vacuous.
|
||||
testDb.prepare("UPDATE trips SET feed_token = 'feed-secret' WHERE id = ?").run(trip.id);
|
||||
dayId = (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').get(trip.id) as { id: number }).id;
|
||||
placeId = createPlace(testDb, trip.id).id;
|
||||
createDayAssignment(testDb, dayId, placeId);
|
||||
createBudgetItem(testDb, trip.id);
|
||||
createPackingItem(testDb, trip.id);
|
||||
createReservation(testDb, trip.id, { day_id: dayId });
|
||||
|
||||
const parts = await createPluginRpcHostParts(testDb);
|
||||
host = parts.factory.create('contract', new Set<string>(KNOWN_PERMISSIONS), {
|
||||
callPlugin: () => Promise.reject(new Error('no peer plugins here')),
|
||||
emitPluginEvent: () => Promise.reject(new Error('no peer plugins here')),
|
||||
});
|
||||
const listing = createTestPluginRegistry(parts.controllers).list();
|
||||
const ctx: PluginRpcContext = {
|
||||
pluginId: 'contract',
|
||||
actingUserId: owner.id,
|
||||
data: undefined as never,
|
||||
plugins: {
|
||||
call: () => Promise.reject(new Error('no peer plugins here')),
|
||||
emit: () => Promise.reject(new Error('no peer plugins here')),
|
||||
},
|
||||
};
|
||||
callHandler = async (method, params) => {
|
||||
const entry = listing.find((e) => e.name === method);
|
||||
const instance = parts.controllers.find((c) => c.constructor.name === entry?.className);
|
||||
if (!entry || !instance) throw new Error(`no handler for ${method}`);
|
||||
const handler = (instance as Record<string, (p: Record<string, unknown>, c: PluginRpcContext) => unknown>)[entry.methodName];
|
||||
return await handler.call(instance, params, ctx);
|
||||
};
|
||||
});
|
||||
|
||||
/** The keys the contract may remove from `raw`: the entity's withheld columns. */
|
||||
const expectOnlyWithheldDropped = (method: string, raw: unknown) => {
|
||||
const output = PLUGIN_METHOD_OUTPUT[method as keyof typeof PLUGIN_METHOD_OUTPUT];
|
||||
if (output.kind !== 'entity') throw new Error(`${method} is not an entity method`);
|
||||
const withheld: readonly string[] = contractOf(output.entity).withheld;
|
||||
const rawRows = (output.many ? raw : [raw]) as Array<Record<string, unknown>>;
|
||||
const shapedRows = (output.many ? shapePluginOutput(method, raw) : [shapePluginOutput(method, raw)]) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(rawRows.length).toBeGreaterThan(0);
|
||||
rawRows.forEach((row, i) => {
|
||||
// Same keys, same order, same values: only a withheld key may go.
|
||||
expect(Object.keys(shapedRows[i])).toEqual(Object.keys(row).filter((k) => !withheld.includes(k)));
|
||||
for (const key of Object.keys(shapedRows[i])) expect(shapedRows[i][key]).toBe(row[key]);
|
||||
});
|
||||
};
|
||||
|
||||
it('OUTCONTRACT-001 the writes return every field they returned before, minus credentials', async () => {
|
||||
const write = async (method: string, params: Record<string, unknown>) => {
|
||||
const raw = await callHandler(method, params);
|
||||
expectOnlyWithheldDropped(method, raw);
|
||||
return raw as { id: number };
|
||||
};
|
||||
await write('trips.update', { tripId, input: { title: 'Renamed' } });
|
||||
await write('trips.create', { input: { title: 'Another trip' } });
|
||||
const place = await write('places.create', { tripId, input: { name: 'Museum', lat: 1, lng: 2 } });
|
||||
await write('places.update', { tripId, placeId: place.id, input: { name: 'Museum of Art' } });
|
||||
const day = await write('days.create', { tripId, input: { notes: 'spare day' } });
|
||||
await write('days.update', { tripId, dayId: day.id, input: { title: 'Spare' } });
|
||||
await write('days.create', { tripId, input: { dated: true } });
|
||||
await write('itinerary.assign', { tripId, dayId, placeId: place.id });
|
||||
const reservation = await write('reservations.create', { tripId, input: { title: 'Flight', type: 'flight' } });
|
||||
await write('reservations.update', { tripId, reservationId: reservation.id, input: { title: 'Flight home' } });
|
||||
const item = await write('packing.create', { tripId, input: { name: 'Socks' } });
|
||||
await write('packing.update', { tripId, itemId: item.id, input: { name: 'Wool socks' } });
|
||||
const content = Buffer.from('boarding pass').toString('base64');
|
||||
const file = await write('files.create', { tripId, input: { name: 'pass.txt', content_base64: content, mimetype: 'text/plain' } });
|
||||
await write('files.update', { tripId, fileId: file.id, input: { description: 'Boarding pass' } });
|
||||
const cost = await write('costs.create', { tripId, input: { name: 'Dinner', total_price: 10 } });
|
||||
await write('costs.update', { tripId, itemId: cost.id, input: { name: 'Lunch' } });
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-002 the reads return every field they returned before, minus credentials', async () => {
|
||||
const reads: Array<[string, Record<string, unknown>]> = [
|
||||
['trips.getById', { tripId }],
|
||||
['trips.listMine', {}],
|
||||
['trips.getPlaces', { tripId }],
|
||||
['trips.getDays', { tripId }],
|
||||
['trips.getReservations', { tripId }],
|
||||
['reservations.listMine', {}],
|
||||
['trips.members', { tripId }],
|
||||
['users.getById', { id: memberId }],
|
||||
['packing.list', { tripId }],
|
||||
['files.list', { tripId }],
|
||||
['costs.getByTrip', { tripId }],
|
||||
['costs.listMine', {}],
|
||||
];
|
||||
for (const [method, params] of reads) expectOnlyWithheldDropped(method, await callHandler(method, params));
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-003 through the router, a trip never carries its feed token', async () => {
|
||||
const dispatch = async (method: string, params: Record<string, unknown>) => {
|
||||
const res = await host.dispatch({ k: 'req', id: 'x', method, params }, ownerId);
|
||||
expect(res.ok).toBe(true);
|
||||
return (res as RpcResponse).result;
|
||||
};
|
||||
const trip = (await dispatch('trips.getById', { tripId })) as Record<string, unknown>;
|
||||
expect(trip.id).toBe(tripId);
|
||||
expect('feed_token' in trip).toBe(false);
|
||||
const mine = (await dispatch('trips.listMine', {})) as Array<Record<string, unknown>>;
|
||||
expect(mine.length).toBeGreaterThan(0);
|
||||
for (const row of mine) {
|
||||
expect('feed_token' in row).toBe(false);
|
||||
expect(row).toHaveProperty('owner_username');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The plugin output contract on its own: how a result is shaped, that the table covers
|
||||
* the wire vocabulary, and that the router applies it to entity methods only. The
|
||||
* schema and real-service side is output-contract.schema.test.ts.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PluginController, PluginMethod } from '../../../src/nest/plugins/host/rpc-kit/decorators';
|
||||
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
||||
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
|
||||
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
|
||||
import { KNOWN_METHODS, UNCONDITIONAL_METHODS, type RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
|
||||
import {
|
||||
PLUGIN_ENTITY_CONTRACT,
|
||||
PLUGIN_METHOD_OUTPUT,
|
||||
pluginEntityFields,
|
||||
returnsEntity,
|
||||
shapePluginOutput,
|
||||
type PluginEntityContract,
|
||||
type PluginEntityName,
|
||||
} from '../../../src/nest/plugins/protocol/output-contract';
|
||||
import { makeDeps } from '../../helpers/rpc-host-deps';
|
||||
|
||||
const ENTITIES = Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[];
|
||||
const contractOf = (entity: PluginEntityName): PluginEntityContract => PLUGIN_ENTITY_CONTRACT[entity];
|
||||
|
||||
describe('the contract table', () => {
|
||||
it('OUTCONTRACT-UNIT-001 has exactly one entry per wire method', () => {
|
||||
expect(Object.keys(PLUGIN_METHOD_OUTPUT).sort()).toEqual([...KNOWN_METHODS, ...UNCONDITIONAL_METHODS].sort());
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-002 names only entities it defines', () => {
|
||||
for (const output of Object.values(PLUGIN_METHOD_OUTPUT)) {
|
||||
if (output.kind === 'entity') expect(ENTITIES).toContain(output.entity);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ENTITIES)('OUTCONTRACT-UNIT-003 %s lists each field once and never publishes a withheld column', (entity) => {
|
||||
const contract = contractOf(entity);
|
||||
const fields = pluginEntityFields(entity);
|
||||
expect(new Set(fields).size).toBe(fields.length);
|
||||
expect(contract.withheld.filter((c) => fields.includes(c))).toEqual([]);
|
||||
expect(fields).toEqual([...contract.columns, ...contract.derived]);
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-004 a trip withholds its feed token and a user exposes four fields', () => {
|
||||
expect(contractOf('trip').withheld).toEqual(['feed_token']);
|
||||
expect(pluginEntityFields('user')).toEqual(['id', 'username', 'display_name', 'avatar']);
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-005 returnsEntity tells the entity methods from the rest', () => {
|
||||
expect(returnsEntity('trips.getById')).toBe(true);
|
||||
expect(returnsEntity('costs.listMine')).toBe(true);
|
||||
expect(returnsEntity('db.query')).toBe(false);
|
||||
expect(returnsEntity('tags.list')).toBe(false);
|
||||
expect(returnsEntity('no.such.method')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapePluginOutput', () => {
|
||||
it('OUTCONTRACT-UNIT-006 keeps the published fields in row order and drops the rest', () => {
|
||||
const nested = { id: 9, name: 'Museum' };
|
||||
const shaped = shapePluginOutput('trips.getById', {
|
||||
title: 'Japan',
|
||||
feed_token: 'secret',
|
||||
id: 1,
|
||||
column_added_later: 'x',
|
||||
day_count: 3,
|
||||
nested_extra: nested,
|
||||
});
|
||||
expect(shaped).toEqual({ title: 'Japan', id: 1, day_count: 3 });
|
||||
expect(Object.keys(shaped as object)).toEqual(['title', 'id', 'day_count']);
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-007 shapes every row of a list and leaves nested values untouched', () => {
|
||||
const assignments = [{ id: 1, place: { id: 2, anything: true } }];
|
||||
const shaped = shapePluginOutput('trips.getDays', [
|
||||
{ id: 1, date: '2026-05-01', assignments, secret_new: 1 },
|
||||
{ id: 2, date: '2026-05-02', notes_items: [] },
|
||||
]) as Array<Record<string, unknown>>;
|
||||
expect(shaped).toEqual([
|
||||
{ id: 1, date: '2026-05-01', assignments },
|
||||
{ id: 2, date: '2026-05-02', notes_items: [] },
|
||||
]);
|
||||
expect(shaped[0].assignments).toBe(assignments);
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-008 a missing row keeps its wire form', () => {
|
||||
expect(shapePluginOutput('users.getById', undefined)).toBeUndefined();
|
||||
expect(shapePluginOutput('trips.getById', null)).toBeNull();
|
||||
// A list method that did not get a list is passed on rather than guessed at.
|
||||
expect(shapePluginOutput('trips.listMine', null)).toBeNull();
|
||||
// A single-row method that got an array (or a primitive) is passed on too.
|
||||
expect(shapePluginOutput('trips.getById', [1])).toEqual([1]);
|
||||
expect(shapePluginOutput('trips.getById', 5)).toBe(5);
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-009 a host or read-model result is returned as the same object', () => {
|
||||
const own = [{ anything: 1 }];
|
||||
expect(shapePluginOutput('db.query', own)).toBe(own);
|
||||
const tags = [{ id: 1, name: 'work', brand_new_column: true }];
|
||||
expect(shapePluginOutput('tags.list', tags)).toBe(tags);
|
||||
expect(shapePluginOutput('no.such.method', tags)).toBe(tags);
|
||||
});
|
||||
});
|
||||
|
||||
/** Two real wire methods: one entity read and one read model. */
|
||||
@PluginController()
|
||||
class ProbeRpc {
|
||||
@PluginMethod('trips.getById', { permission: 'db:read:trips' })
|
||||
trip(_params: Record<string, unknown>, ctx: PluginRpcContext) {
|
||||
return { id: 1, title: 'Japan', feed_token: 'secret', added_later: ctx.actingUserId };
|
||||
}
|
||||
|
||||
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
||||
tags() {
|
||||
return [{ id: 1, name: 'work', added_later: true }];
|
||||
}
|
||||
}
|
||||
|
||||
describe('the router applies the contract', () => {
|
||||
const dispatch = async (method: string, granted: string) => {
|
||||
const host = new PluginRpcHost('p', new Set([granted]), makeDeps(), createTestPluginRegistry([new ProbeRpc()]));
|
||||
const res = await host.dispatch({ k: 'req', id: 'x', method, params: {} }, 42);
|
||||
expect(res.ok).toBe(true);
|
||||
return (res as RpcResponse).result;
|
||||
};
|
||||
|
||||
it('OUTCONTRACT-UNIT-010 a plugin dispatching an entity read receives the shaped row', async () => {
|
||||
expect(await dispatch('trips.getById', 'db:read:trips')).toEqual({ id: 1, title: 'Japan' });
|
||||
});
|
||||
|
||||
it('OUTCONTRACT-UNIT-011 a read-model result reaches the plugin with every key', async () => {
|
||||
expect(await dispatch('tags.list', 'db:read:tags')).toEqual([{ id: 1, name: 'work', added_later: true }]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// GENERATED — do not edit by hand.
|
||||
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
|
||||
// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts
|
||||
// + shared/src/plugins/plugin-poi-facts.ts
|
||||
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
|
||||
|
||||
|
||||
@@ -244,6 +244,29 @@ parser would ignore one if you added it.
|
||||
|
||||
### The `ctx` object
|
||||
|
||||
**What the reads return.** Every method's result is one of three kinds, and the SDK
|
||||
exports the list per method as `PLUGIN_METHOD_RESULT`:
|
||||
|
||||
- **Entity rows** (`trip`, `place`, `day`, `reservation`, `packingItem`, `tripFile`,
|
||||
`budgetItem`, `assignment`, `user`). A row carries exactly the fields TREK publishes
|
||||
for that entity (`PLUGIN_ENTITY_FIELDS` in the SDK): the table's columns in
|
||||
snake_case, then what TREK adds on top, such as a trip's `day_count`,
|
||||
`place_count`, `is_owner`, `owner_username` and `shared_count`, a place's
|
||||
`category`, `tags` and `ratings`, a reservation's `endpoints`, `travelers` and
|
||||
`day_positions`, or a day's `assignments` and `notes_items`. A field appears only
|
||||
where the method's read produces it (`trips.getById` has no `day_count`,
|
||||
`trips.listMine` has). Credentials are never part of a row: a trip's calendar
|
||||
`feed_token` is withheld, and a user is only `id`, `username`, `display_name` and
|
||||
`avatar`. A column TREK adds in a later release is **not** delivered until TREK
|
||||
publishes it in that list, so the rows your plugin reads do not change shape on an
|
||||
update. `Place.day_id` in the SDK types is deprecated and never set: a place's days
|
||||
are the `assignments` of `getDays`.
|
||||
- **Read models** (tags, to-dos, collab notes, polls and messages, journals and
|
||||
entries, bags, collections, accommodations, Atlas and Vacay data): the same shape
|
||||
TREK's own REST route returns, passed on unchanged. The SDK types them `unknown`.
|
||||
- **Host values**: `{ deleted }`, `{ sent }`, a model's answer, an access token, and
|
||||
your own data (`ctx.db`, `ctx.meta`, another plugin's answer), returned as they are.
|
||||
|
||||
| Area | Methods | Requires |
|
||||
|---|---|---|
|
||||
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}\|{rows?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin) and a single result set at **100,000 rows** — page your reads instead of materialising a cartesian product | `db:own` |
|
||||
|
||||
@@ -11,7 +11,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
|
||||
| Permission | Grants | Notes |
|
||||
|---|---|---|
|
||||
| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` — `db.query`, `db.exec`, **and `db.migrate`** | A separate `plugin.db` per plugin under the plugin's data dir — never TREK's own `travel.db`. `db.migrate` runs a keyed, idempotent migration (schema/table creation, e.g. `CREATE TABLE`) once per id. `ATTACH`/`DETACH`/`VACUUM`/`PRAGMA` are refused. |
|
||||
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`, `getDays`, `getAccommodations`, `listMine`, `members`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. `getDays` returns each day with its `assignments` + `notes_items`; `getReservations` rows carry `endpoints` + `day_positions` like the REST list. `members` returns the roster (id + display fields only). |
|
||||
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`, `getDays`, `getAccommodations`, `listMine`, `members`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. `getDays` returns each day with its `assignments` + `notes_items`; `getReservations` rows carry `endpoints` + `day_positions` like the REST list. `members` returns the roster (id + display fields only). Each row carries the fields TREK publishes for it (`PLUGIN_ENTITY_FIELDS` in the SDK), never the trip's calendar `feed_token`, and a column added in a later release is not delivered until it is published. |
|
||||
| `db:read:users` | Read-only public profile via `ctx.users.getById` | Returns id, username, display name, avatar only — **never** password hashes, tokens, or secrets. |
|
||||
| `db:read:packing` | Read-only packing items of a trip via `ctx.packing.list(tripId)` | Membership-checked, and scoped to the acting user's visibility — a plugin never sees another member's private packing items. |
|
||||
| `db:read:files` | Read-only files of a trip via `ctx.files.list(tripId)` | Membership-checked; trashed files excluded. |
|
||||
|
||||
Reference in New Issue
Block a user