From 9dec36995ee0593b65cf1ac0a17f141ce52c29da Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 18:02:39 +0200 Subject: [PATCH] feat(plugins): extend the output allowlist to child rows and whole-row read models The output contract cut only the top level of an entity result. A derived key's value went out untouched, so the children that are whole rows still carried every column: a day's notes_items are full day_notes entities and a reservation's endpoints are SELECT e.* on reservation_endpoints. About fifty methods were also left as readModel and passed through whole, many of them backed by SELECT * reads (day_accommodations, day_notes, todos, tags, packing bags, journal entries, collab, collections, vacay plans). A new column on any of those tables reached every plugin by default, which is what the contract was meant to stop and what the SDK README and the wiki already claimed it did. PLUGIN_ENTITY_NESTED now names, per entity, the derived keys that hold rows of another entity, and the shaper cuts those rows down to their own field list, recursively. dayNote and reservationEndpoint are the child tables that were whole rows; day.assignments and place.tags are held to the assignment and tag lists too, although their reads already name their columns. The remaining derived children (travelers, participants, ratings, budget members, payers and receipts, packing recipients, attachments, voters, bag members) are built field by field, which the contract comment now states as the rule for a derived key without a nested entity. The whole-row read models get entity contracts: accommodation, dayNote, todo, tag, category, packingBag, fileLink, collabNote, collabPoll, collabMessage, journey, journalEntry, bucketItem and collection, and vacayPlan with its holiday calendars. collections.listMine, collections.get and vacay.mine return envelopes around rows; an entity with a null table describes those, so their rows are reached through the same nesting. Every list holds exactly the keys each read returned before, so no plugin loses a field. What stays readModel is built field by field from named columns, and each such entry says what it returns: setBagMembers, atlas.visited, collections.savePlace and copyToTrip, the two vacay toggles and journal.addEntryPhoto. The schema test now covers every child and new table, and calls each newly shaped method on the real services, comparing nested rows key for key so the only field allowed to disappear is a withheld one. A further case proves the nested rows it compares are not empty. gen:plugin-facts publishes PLUGIN_ENTITY_NESTED to the SDK, and the SDK README, the plugin wiki pages and server/CLAUDE.md describe the nested rows and list the new entities. --- plugin-sdk/CLAUDE.md | 2 +- plugin-sdk/README.md | 54 ++- plugin-sdk/src/generated/host-facts.ts | 355 ++++++++++++++++-- plugin-sdk/src/index.ts | 5 +- plugin-sdk/test/output-contract.test.ts | 18 +- server/CLAUDE.md | 2 +- server/scripts/gen-plugin-facts.ts | 20 +- .../nest/plugins/protocol/output-contract.ts | 347 ++++++++++++++--- .../plugins/output-contract.schema.test.ts | 236 +++++++++--- .../unit/plugins/output-contract.test.ts | 87 ++++- wiki/Plugin-Development.md | 37 +- wiki/Plugin-Permissions.md | 2 +- 12 files changed, 973 insertions(+), 192 deletions(-) diff --git a/plugin-sdk/CLAUDE.md b/plugin-sdk/CLAUDE.md index f8c288e26..a5bf48b14 100644 --- a/plugin-sdk/CLAUDE.md +++ b/plugin-sdk/CLAUDE.md @@ -30,7 +30,7 @@ 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, `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/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_ENTITY_NESTED` / `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) | `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) | diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index d0160430c..f5a715ef3 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -132,25 +132,39 @@ ungranted call throws `PERMISSION_DENIED`. 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. +- **An entity** (a list where the entry ends in `[]`). The trip data: `trip`, + `place`, `day`, `reservation`, `accommodation`, `packingItem`, `packingBag`, + `tripFile`, `fileLink`, `budgetItem`, `assignment`, `dayNote`, `todo` and `user`; + the collab board: `collabNote`, `collabPoll` and `collabMessage`; the acting + user's own data: `tag`, `category`, `journey`, `journalEntry`, `bucketItem` and + `collection`. 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 same holds for the rows inside a row. `PLUGIN_ENTITY_NESTED` names, per entity, + the fields that hold rows of another entity (a day's `notes_items` are `dayNote` + rows, a reservation's `endpoints` are `reservationEndpoint` rows), and those carry + that entity's fields and no others, however deep they sit. Every other added field + (counts, `{ user_id, username }` lists, id lists, a place's `category`) is a value + TREK builds field by field. Three results are envelopes around rows rather than a + row: `collections.listMine` (`collectionListing`), `collections.get` + (`collectionDetail`) and `vacay.mine` (`vacayPlanData`). + + 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`**: a small result TREK builds field by field from named columns, so + no stored row passes through it whole: a bag's members, the Atlas `visited` codes, + what a Vacay toggle did, a collection's saved place or copy summary, and a journal + photo. 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. @@ -375,7 +389,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)). +- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_ENTITY_NESTED`, `PLUGIN_METHOD_RESULT`: what each ctx method returns, which fields each entity row carries and which of them hold rows of another entity (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/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index c58507d9e..d54cb8a76 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -622,8 +622,291 @@ export const PLUGIN_ENTITY_FIELDS = { 'display_name', 'avatar', ], + dayNote: [ + 'id', + 'day_id', + 'trip_id', + 'text', + 'time', + 'icon', + 'sort_order', + 'created_at', + 'color', + ], + reservationEndpoint: [ + 'id', + 'reservation_id', + 'role', + 'sequence', + 'name', + 'code', + 'lat', + 'lng', + 'timezone', + 'local_time', + 'local_date', + 'created_at', + ], + tag: [ + 'id', + 'user_id', + 'name', + 'color', + 'created_at', + ], + todo: [ + 'id', + 'trip_id', + 'name', + 'checked', + 'category', + 'sort_order', + 'due_date', + 'description', + 'assigned_user_id', + 'priority', + 'created_at', + 'reminded_at', + ], + accommodation: [ + 'id', + 'trip_id', + 'place_id', + 'start_day_id', + 'end_day_id', + 'check_in', + 'check_in_end', + 'check_out', + 'confirmation', + 'notes', + 'created_at', + 'place_name', + 'place_address', + 'place_image', + 'place_lat', + 'place_lng', + 'reservation_title', + ], + packingBag: [ + 'id', + 'trip_id', + 'name', + 'color', + 'weight_limit_grams', + 'sort_order', + 'created_at', + 'user_id', + 'assigned_username', + 'members', + 'total_weight_grams', + ], + fileLink: [ + 'id', + 'file_id', + 'reservation_id', + 'assignment_id', + 'place_id', + 'created_at', + 'budget_item_id', + ], + collabNote: [ + 'id', + 'trip_id', + 'user_id', + 'category', + 'title', + 'content', + 'color', + 'pinned', + 'created_at', + 'updated_at', + 'website', + 'username', + 'avatar', + 'avatar_url', + 'attachments', + ], + collabPoll: [ + 'id', + 'trip_id', + 'user_id', + 'question', + 'options', + 'multiple', + 'closed', + 'deadline', + 'created_at', + 'username', + 'avatar', + 'avatar_url', + 'is_closed', + 'multiple_choice', + ], + collabMessage: [ + 'id', + 'trip_id', + 'user_id', + 'text', + 'reply_to', + 'created_at', + 'deleted', + 'username', + 'avatar', + 'reply_text', + 'reply_username', + 'user_avatar', + 'avatar_url', + 'reactions', + 'attachments', + ], + journey: [ + 'id', + 'user_id', + 'title', + 'subtitle', + 'cover_gradient', + 'status', + 'created_at', + 'updated_at', + 'cover_image', + 'show_trip_tracks', + 'show_verdict', + 'show_mood', + 'show_weather', + 'status_override', + 'photo_location', + 'entry_count', + 'photo_count', + 'place_count', + 'trip_date_min', + 'trip_date_max', + ], + journalEntry: [ + 'id', + 'journey_id', + 'source_trip_id', + 'source_place_id', + 'author_id', + 'type', + 'title', + 'story', + 'entry_date', + 'entry_time', + 'location_name', + 'location_lat', + 'location_lng', + 'mood', + 'weather', + 'tags', + 'visibility', + 'sort_order', + 'created_at', + 'updated_at', + 'pros_cons', + 'stats_excluded', + 'dismissed', + 'country_code', + 'source_assignment_id', + 'is_draft', + 'photos', + 'source_trip_name', + ], + category: [ + 'id', + 'name', + 'color', + 'icon', + 'user_id', + 'created_at', + ], + bucketItem: [ + 'id', + 'user_id', + 'name', + 'lat', + 'lng', + 'country_code', + 'notes', + 'created_at', + 'target_date', + 'visited_at', + 'visited_source', + 'region_code', + ], + collection: [ + 'id', + 'owner_id', + 'name', + 'description', + 'color', + 'icon', + 'cover_image', + 'links', + 'sort_order', + 'created_at', + 'updated_at', + 'place_count', + 'members', + 'is_owner', + 'labels', + ], + collectionListing: [ + 'collections', + 'incomingInvites', + ], + collectionDetail: [ + 'collection', + 'places', + ], + vacayPlan: [ + 'id', + 'owner_id', + 'block_weekends', + 'holidays_enabled', + 'holidays_region', + 'school_holidays_enabled', + 'company_holidays_enabled', + 'carry_over_enabled', + 'created_at', + 'weekend_days', + 'week_start', + 'holiday_calendars', + ], + vacayHolidayCalendar: [ + 'id', + 'plan_id', + 'type', + 'region', + 'label', + 'color', + 'sort_order', + ], + vacayPlanData: [ + 'plan', + 'users', + 'pendingInvites', + 'incomingInvites', + 'isOwner', + 'isFused', + ], } as const; +/** + * The fields of an entity that hold rows of another entity (one row or a list), + * and which one. Those rows carry that entity's PLUGIN_ENTITY_FIELDS and no others, + * however deep they sit, so a column TREK adds to a child table is held back the + * same way. A field not listed here holds a value TREK builds field by field. + */ +export const PLUGIN_ENTITY_NESTED: Readonly>>> = { + place: { tags: 'tag' }, + day: { assignments: 'assignment', notes_items: 'dayNote' }, + reservation: { endpoints: 'reservationEndpoint' }, + collectionListing: { collections: 'collection' }, + collectionDetail: { collection: 'collection' }, + vacayPlan: { holiday_calendars: 'vacayHolidayCalendar' }, + vacayPlanData: { plan: 'vacayPlan' }, +}; + /** * 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 @@ -639,54 +922,54 @@ export const PLUGIN_METHOD_RESULT: Readonly> = { 'trips.getPlaces': 'place[]', 'trips.getReservations': 'reservation[]', 'trips.getDays': 'day[]', - 'trips.getAccommodations': 'readModel', + 'trips.getAccommodations': 'accommodation[]', 'trips.listMine': 'trip[]', 'reservations.listMine': 'reservation[]', 'reservations.create': 'reservation', 'reservations.update': 'reservation', 'reservations.delete': 'host', - 'accommodations.create': 'readModel', - 'accommodations.update': 'readModel', + 'accommodations.create': 'accommodation', + 'accommodations.update': 'accommodation', '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.listBags': 'packingBag[]', + 'packing.createBag': 'packingBag', + 'packing.updateBag': 'packingBag', 'packing.deleteBag': 'host', 'packing.setBagMembers': 'readModel', 'files.list': 'tripFile[]', 'files.getContent': 'host', 'files.create': 'tripFile', - 'files.createLink': 'readModel', + 'files.createLink': 'fileLink[]', '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', + 'collab.listNotes': 'collabNote[]', + 'collab.listPolls': 'collabPoll[]', + 'collab.listMessages': 'collabMessage[]', + 'collab.createNote': 'collabNote', + 'collab.createPoll': 'collabPoll', + 'collab.votePoll': 'collabPoll', + 'collab.createMessage': 'collabMessage', 'trips.addMember': 'host', 'trips.removeMember': 'host', 'trips.create': 'trip', - 'journal.listMine': 'readModel', - 'journal.getEntries': 'readModel', + 'journal.listMine': 'journey[]', + 'journal.getEntries': 'journalEntry[]', 'atlas.visited': 'readModel', - 'atlas.bucketList': 'readModel', + 'atlas.bucketList': 'bucketItem[]', 'rates.get': 'host', - 'vacay.mine': 'readModel', - 'daynotes.list': 'readModel', - 'daynotes.create': 'readModel', - 'daynotes.update': 'readModel', + 'vacay.mine': 'vacayPlanData', + 'daynotes.list': 'dayNote[]', + 'daynotes.create': 'dayNote', + 'daynotes.update': 'dayNote', 'daynotes.delete': 'host', - 'collections.listMine': 'readModel', - 'collections.get': 'readModel', - 'collections.create': 'readModel', - 'collections.update': 'readModel', + 'collections.listMine': 'collectionListing', + 'collections.get': 'collectionDetail', + 'collections.create': 'collection', + 'collections.update': 'collection', 'collections.savePlace': 'readModel', 'collections.copyToTrip': 'readModel', 'collections.deletePlace': 'host', @@ -694,26 +977,26 @@ export const PLUGIN_METHOD_RESULT: Readonly> = { 'atlas.unmarkCountry': 'host', 'atlas.markRegion': 'host', 'atlas.unmarkRegion': 'host', - 'atlas.createBucketItem': 'readModel', + 'atlas.createBucketItem': 'bucketItem', 'atlas.deleteBucketItem': 'host', 'vacay.toggleEntry': 'readModel', 'vacay.toggleCompanyHoliday': 'readModel', - 'journal.createEntry': 'readModel', + 'journal.createEntry': 'journalEntry', 'journal.addEntryPhoto': 'readModel', - 'journal.updateEntry': 'readModel', + 'journal.updateEntry': 'journalEntry', 'journal.deleteEntry': 'host', - 'journal.createJourney': 'readModel', + 'journal.createJourney': 'journey', 'journal.deleteJourney': 'host', 'weather.get': 'host', - 'categories.list': 'readModel', - 'tags.list': 'readModel', - 'tags.create': 'readModel', - 'tags.update': 'readModel', + 'categories.list': 'category[]', + 'tags.list': 'tag[]', + 'tags.create': 'tag', + 'tags.update': 'tag', 'tags.delete': 'host', 'trips.members': 'user[]', - 'todos.list': 'readModel', - 'todos.create': 'readModel', - 'todos.update': 'readModel', + 'todos.list': 'todo[]', + 'todos.create': 'todo', + 'todos.update': 'todo', 'todos.delete': 'host', 'costs.getByTrip': 'budgetItem[]', 'costs.listMine': 'budgetItem[]', diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 1ca2191b5..f98a6a701 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -1007,8 +1007,9 @@ 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'; +// What ctx results carry: the published fields of each entity, the fields holding rows +// of another entity, and what each method returns. +export { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_RESULT } from './generated/host-facts.js'; // The lucide icons a `capabilities.poiCategories` entry may use, and the per-plugin cap. export { POI_CATEGORY_ICONS, POI_CATEGORY_MAX } from './generated/host-facts.js'; diff --git a/plugin-sdk/test/output-contract.test.ts b/plugin-sdk/test/output-contract.test.ts index 0f16007b7..c92f2169c 100644 --- a/plugin-sdk/test/output-contract.test.ts +++ b/plugin-sdk/test/output-contract.test.ts @@ -5,7 +5,7 @@ * 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 { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_RESULT } from '../src/index.js'; import { KNOWN_ADDONS } from '../src/manifest.js'; import { KNOWN_METHODS } from '../src/generated/host-facts.js'; @@ -26,6 +26,20 @@ describe('PLUGIN_ENTITY_FIELDS', () => { }); }); +describe('PLUGIN_ENTITY_NESTED', () => { + it('points the fields that hold rows at entities with a field list', () => { + const fields: Readonly> = PLUGIN_ENTITY_FIELDS; + for (const [entity, nested] of Object.entries(PLUGIN_ENTITY_NESTED)) { + for (const [field, child] of Object.entries(nested)) { + expect(fields[entity]).toContain(field); + expect(fields[child]).toBeDefined(); + } + } + expect(PLUGIN_ENTITY_NESTED.day).toEqual({ assignments: 'assignment', notes_items: 'dayNote' }); + expect(PLUGIN_ENTITY_NESTED.reservation).toEqual({ endpoints: 'reservationEndpoint' }); + }); +}); + 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(); @@ -41,6 +55,8 @@ describe('PLUGIN_METHOD_RESULT', () => { expect(PLUGIN_METHOD_RESULT['trips.getById']).toBe('trip'); expect(PLUGIN_METHOD_RESULT['trips.getPlaces']).toBe('place[]'); expect(PLUGIN_METHOD_RESULT['db.query']).toBe('host'); + expect(PLUGIN_METHOD_RESULT['tags.list']).toBe('tag[]'); + expect(PLUGIN_METHOD_RESULT['atlas.visited']).toBe('readModel'); }); }); diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 13e231800..1e6316650 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. 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). A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `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 result (`trips.getById`, `todos.list`, `vacay.mine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, together with every child row it carries whose field is listed in `PLUGIN_ENTITY_NESTED` (a day's `notes_items`, a reservation's `endpoints`). A new column on any whole-row table behind those entities, child tables included, fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` and `PLUGIN_ENTITY_NESTED` are generated from it); a method left as `readModel` must return a value built field by field from named columns. A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `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--