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--