Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL
CI / full-gate (push) Failing after 23s

This commit is contained in:
2026-08-19 00:08:35 +02:00
parent bba048e38f
commit ae8f105360
11 changed files with 313 additions and 129 deletions
+23 -10
View File
@@ -39,7 +39,7 @@ branch, create a PR and merge it when the CI/CD turns green.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
Postgres database, which the host provisions and never reads or writes. Auth/identity/OAuth are
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
service or their own database, not as core code.
@@ -82,15 +82,28 @@ Revisit only if the stated reason stops holding.
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
`plugin-api.test.ts` guards by asserting both paths reach one module.
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
`postgres` to run the provisioning DDL in `bootstrap` and nothing else: it is never re-exported
through the barrel, so no driver shape enters the contract and a plugin depends on whichever client
it likes. Three properties hold the design together, so don't trade one away in isolation:
passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which is what keeps the host
stateless; the superuser DSN reaches `bootstrap` only, because plugin code runs inside `web` and
can read that process' environment (`src/compose.test.ts` guards the split); and provisioning
never drops anything, so uninstalling a plugin cannot destroy data. Because the host's copy sits in
the ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — that is
incidental, not a packaging promise, and a plugin must still depend on its own driver.
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
both halves; the claim silently went false once already). It is never re-exported through the
barrel, so no driver shape enters the contract. Three properties hold the design together, so
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
packaging promise, and a plugin must still depend on its own driver.
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
containment, and README says so rather than implying a sandbox. Consistent with priority #7
(crash-isolation is a non-goal). `server.ts` still derives every credential *before* the boot hooks
and then deletes the secret from `process.env` — defence in depth, and the ordering is the whole
point, so don't move it. **Valid while plugins are operator-installed code, not third-party uploads.**
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** While
`HOST_API_VERSION` is frozen both are free to revisit; after the freeze, adding is compatible and
removing is not, so the shape errs small elsewhere. Pools handed to a plugin are reaped on process
exit — revisit if a plugin ever needs an orderly drain. **Valid while the freeze holds.**
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment.