# Plug-ins The plug-in layer (PRD REQ-PLUG-1, build step 6b, Gitea #1626). Every part the core calls out to is a plug-in kind. A module of a kind is a directory in this package, and the organization file says which modules are switched on. No core module imports a plug-in module by name; calls go through the loader. | Path | What it is | |---|---| | `src/kinds.mjs` | the kind registry and the check for the organization file's `plugins` key | | `src/loader.mjs` | loads the switched-on modules from this tree | | `//index.mjs` | one module of a kind | ## Kinds | Kind | Interface | Switch | Exports | |---|---|---|---| | `usage-source` | 1 | list | `interfaceVersion`, `readTurn` | | `limit-check` | 1 | list | `interfaceVersion`, `mayLaunch`, `mustStop` | Every module exports `interfaceVersion`, equal to its kind's interface version, plus the kind's functions. PRD REQ-LIMIT-4 says what the two kinds do: a usage source reads what a session used after each turn, and a limit check answers whether a launch may start (`mayLaunch`) and whether a running session must stop at the end of its turn (`mustStop`). The arguments and return values of these functions are set by the rows that call them (row 124 converts the first usage source). No module of either kind ships yet. Adding a kind, or changing a kind's exports, interface version or switch, is a core change: a reviewed edit to `src/kinds.mjs`, with the code that calls the kind. ## Adding a module 1. Pick a name matching `^[a-z][a-z0-9-]*$`. 2. Create `packages/plugins///index.mjs`. A real directory and a real file: the loader refuses a symlink anywhere from `packages/plugins` down, `index.mjs` included. 3. Export `interfaceVersion` and every function the kind lists. 4. Land it like any other change. A running stack sees it only after a re-pin, because `scripts/stack-copy.sh` copies the whole `packages/plugins` directory into the pinned copy. 5. Switch it on in the organization file's `plugins` object, under its kind. The host setter `packages/business/src/plugin-settings.mjs` is the only writer of that key. Adding a module changes no core file. ## The organization file's `plugins` key One key per kind. A list kind takes a list of names; a kind that switches on one module takes one name. An absent key switches the kind off. ```json "plugins": { "usage-source": ["meter"], "limit-check": [] } ``` `validateBusinessDocument` checks the shape: known kinds, the name pattern, no name twice. It doesn't check that a module exists; the loader does that when it loads. The setter writes through the organization writer, which logs actor and old and new names in `changes.jsonl` like a limit change. A refused write throws `OrganizationError` (`invalid-plugins` or `invalid-request`) and logs nothing. ## The loader ```js import { createLoader } from "../packages/plugins/src/loader.mjs"; const loader = createLoader({ log: entry => record(entry) }); const { loaded, refused } = await loader.load(business.plugins); loader.modules("limit-check"); // the switched-on modules, in order loader.loaded(); // { kind: [names actually switched on] } ``` The loader finds this package from its own location and takes no path from config. Until Jason rules on Vikunja #286 it loads modules from this tree only (lead decision 143). For each name it checks, in order: 1. the name matches `^[a-z][a-z0-9-]*$` (`bad-name`); 2. `packages/plugins`, ``, `` and `index.mjs` exist, are a directory or a regular file as expected, and are not symlinks (`missing`, `symlink`); ancestors of `packages/plugins` are not checked; 3. just before import, `realpath(index.mjs)` is still the expected path (`symlink`); 4. the module imports (`load-failed`); 5. it has every export, and the kind's functions are functions (`missing-export`); 6. `interfaceVersion` equals the kind's (`interface-version`). At steps 2 and 3, a path that can't be read for another reason, such as a permission error, refuses as `unreadable`. A refusal refuses the whole kind for that load: the kind keeps the modules it had, and the loader calls `log` with `{event: "plugin-refused", kind, name, code, message, kept}`. A kind refused on its first load has nothing switched on. An unknown kind refuses only itself; a `plugins` value that isn't an object refuses every kind. Loads run one at a time. Limits of the checks: - A module runs with the host's authority once imported. The checks keep the loader inside this tree; they don't sandbox the module. - A module's top-level code runs at import, before the export and interface-version checks. A module refused for `missing-export` or `interface-version` has still run, and Node's cache keeps it. - Someone who can write this tree can swap a file between the realpath check and the import. The checks catch mistakes and stale links, not a writer racing the host as the same user. - Node caches a module by URL, so a changed `index.mjs` is not re-read until the process restarts. - The loader is not wired into any host process yet. The rows that call the first two kinds (124, 125, 126) wire it.