From 95f9f031c4734e58533add222e30382a0e2bac47 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 17:41:59 +0200 Subject: [PATCH] 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. --- plugin-sdk/CLAUDE.md | 4 +- plugin-sdk/README.md | 29 ++ plugin-sdk/package-lock.json | 4 +- plugin-sdk/package.json | 2 +- plugin-sdk/src/generated/host-facts.ts | 358 ++++++++++++++++++ plugin-sdk/src/index.ts | 39 +- plugin-sdk/src/manifest.ts | 9 +- plugin-sdk/test/output-contract.test.ts | 51 +++ server/CLAUDE.md | 2 +- server/scripts/gen-plugin-facts.ts | 49 ++- server/src/nest/plugins/host/rpc-host.ts | 9 +- .../nest/plugins/protocol/output-contract.ts | 321 ++++++++++++++++ server/tests/helpers/plugin-host.ts | 18 +- .../plugins/output-contract.schema.test.ts | 187 +++++++++ .../unit/plugins/output-contract.test.ts | 135 +++++++ shared/src/plugin-permissions.ts | 1 + wiki/Plugin-Development.md | 23 ++ wiki/Plugin-Permissions.md | 2 +- 18 files changed, 1221 insertions(+), 22 deletions(-) create mode 100644 plugin-sdk/test/output-contract.test.ts create mode 100644 server/src/nest/plugins/protocol/output-contract.ts create mode 100644 server/tests/unit/plugins/output-contract.schema.test.ts create mode 100644 server/tests/unit/plugins/output-contract.test.ts diff --git a/plugin-sdk/CLAUDE.md b/plugin-sdk/CLAUDE.md index e8b7336f3..f8c288e26 100644 --- a/plugin-sdk/CLAUDE.md +++ b/plugin-sdk/CLAUDE.md @@ -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 | diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index cc8553717..3ce979647 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -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`). diff --git a/plugin-sdk/package-lock.json b/plugin-sdk/package-lock.json index 077381960..95a0b65c8 100644 --- a/plugin-sdk/package-lock.json +++ b/plugin-sdk/package-lock.json @@ -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", diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index a7a8e66e3..46b4ec0c6 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -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", diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index b59c266c8..c58507d9e 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -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> = { + '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', +}; diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 6882c7404..c8ac0002f 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -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 = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] }; +type Undelivered = Exclude, (typeof PLUGIN_ENTITY_FIELDS)[E][number]>; +type NoneUndelivered = T; +type EntityFieldsAreDelivered = [ + NoneUndelivered>, + NoneUndelivered, 'day_id'>>, + NoneUndelivered>, + NoneUndelivered>, + NoneUndelivered>, + NoneUndelivered>, + NoneUndelivered>, + NoneUndelivered>, + NoneUndelivered>, +]; + /** 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'; diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 8be61a1a4..57e687179 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -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). diff --git a/plugin-sdk/test/output-contract.test.ts b/plugin-sdk/test/output-contract.test.ts new file mode 100644 index 000000000..0f16007b7 --- /dev/null +++ b/plugin-sdk/test/output-contract.test.ts @@ -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'])); + }); +}); diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 9ffe35e45..d6e1c517e 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`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 `.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--