feat(modules): the declarative Docker path (phase 4, slice 3)
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s

MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.

Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.

A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.

Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.

741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 07:57:08 -05:00
parent 75f4d29e93
commit 9b16f39a52
15 changed files with 1090 additions and 28 deletions

View File

@@ -1,8 +1,12 @@
require('dotenv').config()
const http = require('http')
const app = require('./app')
const internalApp = require('./internalApp')
// NOTE: `./app` and `./internalApp` are deliberately NOT required here. Requiring
// app.js runs `modules.load()`, which scans the volume and mounts whatever is on
// it (MODULE_API.md §4.1) — so the declared module set has to be resolved before
// that require, not before the listener. They are required inside start(), after
// resolveDeclaredModules(); everything else this file needs is safe to pull in
// now because none of it reaches the loader's scan.
const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const { ensureSchema, close } = require('./utils/db')
@@ -11,6 +15,9 @@ const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
const moduleLifecycle = require('./modules/lifecycle')
const declaredModules = require('./modules/declared')
const moduleInstall = require('./modules/install')
const moduleModel = require('./model/modules/modules.model')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const brand = require('./config/brand')
@@ -51,7 +58,9 @@ async function start() {
}
log.info('ensuring database schema...')
await ensureSchema()
// Core's schema only. Each installed module's fragment is replayed further
// down, after the volume has been scanned — see the require of ./app below.
await ensureSchema({ replayModules: false })
log.info('seeding defaults...')
await seedDefaults()
await createInitialAdminFromEnv()
@@ -77,6 +86,36 @@ async function start() {
const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
// Bring the modules volume in line with what MODULES declares (§2.7.2
// decision 4), and only then require the app — the loader scans and mounts at
// require time, so this is the last moment at which a module can be put on the
// volume and still be part of this process.
//
// After the schema and the seed, because the host allowlist it installs under
// is a settings row that the seed creates on a fresh instance. Never throws:
// an unreachable release host leaves the site serving without that module
// rather than taking the site down with it.
await declaredModules.resolve({
hosts: moduleInstall.parseHosts(await settings.get(moduleInstall.HOSTS_SETTING)),
model: moduleModel,
})
// Requiring app.js is what scans the volume and mounts what is on it. Every
// line above this one runs against a core that has no modules in it yet.
// eslint-disable-next-line global-require
const app = require('./app')
// eslint-disable-next-line global-require
const internalApp = require('./internalApp')
// Now that the scan has happened, replay each module's schema fragment
// (MODULE_API.md §2.6). This used to ride inside ensureSchema() and could,
// because app.js was required at the top of this file; resolving the declared
// set first moved the scan after it, and a booting server quietly getting no
// module tables is precisely what §7.6 warns about. Caught by the browser
// smoke rather than by a test: every suite here stubs one side or the other.
// eslint-disable-next-line global-require
await require('./modules/schema').replayFragments()
// Reconcile installed_modules with what the loader found on the volume at
// require time, then run each module's onBoot (MODULE_API.md §2.5).
//