Merge pull request 'feat(module): the bundle skeleton (phase 3, slice 0)' (#2) from feature/module-bundle-skeleton into main

Reviewed-on: #2
This commit is contained in:
2026-08-11 06:43:38 +00:00
21 changed files with 4002 additions and 71 deletions

View File

@@ -5,25 +5,33 @@
# shaped like that repo's `server/` and `client/`, and it is loaded into that # shaped like that repo's `server/` and `client/`, and it is loaded into that
# repo's process, so it is checked the same way with the same Node version. # repo's process, so it is checked the same way with the same Node version.
# #
# ── Package guard ──────────────────────────────────────────────────────────── # ── What each job is really asking ───────────────────────────────────────────
# This repo is in the planning phase and has no module code yet: the API contract
# is settled in Phase 1 and Phase 3 is what extracts the UO half of `website/`
# into this repo (docs/website/MODULE_SYSTEM.md §2.7). Rather than leave the repo
# ungated until then — or land a workflow that red-Xes every governance/docs PR —
# each half's gates are conditional on its package.json existing. Before the code
# lands, the job reports green with a notice saying so. The moment a package.json
# appears the gates arm themselves; nothing here has to change.
# #
# The same trick guards the client build, which is the higher-risk half: it must # The tests are the ordinary half. The two `check:*` scripts are the interesting
# build with Vite in library mode with react/react-dom/react-router-dom EXTERNAL, # one, because they are the acceptance criteria of the module contract itself
# because there is exactly one React instance in the page and core owns it. A # (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
# module that bundles its own React loads and then breaks hooks at runtime, which #
# is precisely the kind of failure worth catching before merge. # • `server: check:imports` — no relative path escapes the module root, and no
# shipped file resolves a bare specifier. A module that reaches into core's
# tree works right up until core moves a file, and the whole boundary is
# worth exactly as much as this check is (§5.1).
#
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
# failure is invisible in source: `import { useState } from 'react'` is
# correct in every file, and whether it becomes core's React or a bare
# specifier no browser can resolve is decided by vite.config.js. It has to
# be asked of the artifact, so it runs after the build. (The other half —
# a shared dependency being BUNDLED — fails the build itself, from a
# resolution-time guard inside vite.config.js.)
#
# Building the chunk in CI is not only a check: it is how the chunk that ships is
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
# #
# Not here yet, deliberately, because there is nothing for them to act on until # Not here yet, deliberately, because there is nothing for them to act on until
# Phase 3: a release workflow (the `module-uo-<version>.tar.gz` artifact and its # the extraction is further along: the release workflow (the
# sha256 manifest), the zero-internal-imports check, and the module's own frozen # `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
# route manifest. Each lands with the code it checks. # own frozen route manifest, which needs core checked out at a pinned ref
# (MODULE_API.md §5.3). Each lands with the slice it checks.
# #
# Enforcement (one-time, in the Gitea UI): # Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`) # Repository Settings → Branches → Branch Protection (rule for `main`)
@@ -54,22 +62,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Detect whether the server half exists yet
id: detect
run: |
set -euo pipefail
if [ -f server/package.json ]; then
echo "pkg=true" >> "$GITHUB_OUTPUT"
echo "==> server/package.json found - running the server gates."
else
echo "pkg=false" >> "$GITHUB_OUTPUT"
echo "==> No server/package.json yet (planning phase)."
echo " Skipping install/test. These gates arm themselves as soon"
echo " as Phase 3 lands the server half - see MODULE_SYSTEM.md."
fi
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: ${{ steps.detect.outputs.pkg == 'true' }}
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
@@ -78,50 +71,34 @@ jobs:
# `npm ci` rather than `npm install`: it also proves the lockfile is in # `npm ci` rather than `npm install`: it also proves the lockfile is in
# sync with package.json instead of silently updating it. # sync with package.json instead of silently updating it.
- name: Install server deps - name: Install server deps
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm ci --prefix server run: npm ci --prefix server
- name: Run server tests - name: Run server tests
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm test --prefix server run: npm test --prefix server
- name: Check the module boundary (MODULE_API.md §5.1)
run: npm run check:imports --prefix server
client-build: client-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Detect whether the client half exists yet
id: detect
run: |
set -euo pipefail
if [ -f client/package.json ]; then
echo "pkg=true" >> "$GITHUB_OUTPUT"
echo "==> client/package.json found - running the client gates."
else
echo "pkg=false" >> "$GITHUB_OUTPUT"
echo "==> No client/package.json yet (planning phase)."
echo " Skipping install/test/build. These gates arm themselves as"
echo " soon as Phase 3 lands the client half."
fi
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
if: ${{ steps.detect.outputs.pkg == 'true' }}
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: client/package-lock.json cache-dependency-path: client/package-lock.json
- name: Install client deps - name: Install client deps
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm ci --prefix client run: npm ci --prefix client
- name: Run client tests - name: Run client tests
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm test --prefix client run: npm test --prefix client
# Building the ESM chunk in CI is not just a check: it is how the chunk
# that ships in the release is produced, since an operator never builds.
- name: Build the client chunk - name: Build the client chunk
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm run build --prefix client run: npm run build --prefix client
- name: Check the built chunk's externals (MODULE_API.md §3.6)
run: npm run check:externals --prefix client

View File

@@ -24,36 +24,69 @@ The module's **id** is `uo` — that is what appears in `module.json`, in the `i
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`, table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact. `/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
## Status: planning — no module code exists yet ## Status: the bundle skeleton exists; the extraction has started
This repo currently holds governance scaffolding only. The design of record is The design of record is
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md) [`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
in the docs repo — **read it before opening a PR here.** It defines the module API surface, the and the normative contract is
packaging, the state machine, the install/uninstall/purge model, and the phases. [`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
in the docs repo — **read them before opening a PR here.** Where the two differ, the contract wins.
| Phase | Where it happens | State | | Phase | Where it happens | State |
|---|---|---| |---|---|---|
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | 🟡 in progress | | 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done |
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ⬜ blocking | | 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done |
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | | | 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done |
| 3 — extract the UO half of the site into this repo | `website`, here | | | 3 — extract the UO half of the site into this repo | `website`, here | 🟡 in progress |
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ | | 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
Nothing lands here until Phase 1 has settled the contract this module is written against. Phase 3 is Phase 3 moves the UO half of `website/` here in ten slices (`MODULE_SYSTEM.md` §2.7.1), server-first
what fills the repo. and then client. Each slice is one PR here that adds, and one PR in `website` that deletes — this one
merging first, so `website`'s `edge` branch serves the feature from core right up to the moment core
drops it.
## What it will contain **Slice 0 is the bundle skeleton, and it registers nothing on purpose.** What it proves is the
delivery path itself: core discovers the module, validates `module.json`, calls `register()`, serves
the client chunk, injects it, and reports the module `started` — and the chunk resolves React, the
renderer and the router from core's `window.__rg` rather than bundling its own. Every slice after
this one adds registrations to `server/index.js` and `client/src/entry.jsx`.
## Working on it
```bash
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
```
The two `check:*` scripts are the contract's acceptance criteria rather than this module's own tests:
no import may escape the module root (`MODULE_API.md` §5.1), and no bare specifier may survive into
the built chunk (§3.6). The matching failure — a shared dependency being *bundled* — fails the build
itself, from a guard inside `vite.config.js`.
Running it against a real core means checking this repo out as `website/modules/uo`, building the
client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing
that proves the client half works: its real failure modes are timing and module resolution, and
neither has a shape a DOM-less test runner can see.
## What it contains
One repo, one bundle: the server half and the client half live side by side and version together, so One repo, one bundle: the server half and the client half live side by side and version together, so
a route and the screen that calls it can never be mismatched. a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
``` ```
module.json id, version, coreApi range, mounts, extensions module.json id, version, coreApi range, mounts, extensions
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
server/test/ ✅ node --test, with a fake ctx standing in for core
server/ routers, controllers, models, utils server/ routers, controllers, models, utils
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema() server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge server/db/purge.sql destructive; only ever run by an explicit purge
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
client/src/ route components, nav registrations, feature provider client/src/ route components, nav registrations, feature provider
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
``` ```
Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`. Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`.

1792
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "module-uo-client",
"version": "0.1.0",
"private": true,
"description": "Client half of module-uo — a prebuilt ESM chunk core injects into its own SPA",
"license": "GPL-3.0-or-later",
"type": "module",
"scripts": {
"build": "vite build",
"test": "node --test",
"check:externals": "node scripts/checkExternals.js"
},
"engines": {
"node": ">=20"
},
"//dependencies": "Deliberately none that ship. react, react-dom, react-dom/client and react-router-dom are EXTERNAL in the Vite build and arrive at runtime on window.__rg — there is exactly one React in the page and core owns it (MODULE_API.md §3.2, §7.2). They are devDependencies only so Vite and the JSX transform can typecheck and resolve during the build.",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
//
// The server half's boundary check reads source. The client half's has to read
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
// source: `import { useState } from 'react'` is correct in every file, and
// whether it ends up as core's React or as a second copy welded into the chunk
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
// see until a hook throws in the browser.
//
// So: build, then ask the artifact two questions.
//
// 1. **Is there a bare import left?** There must not be. Aliased shims are
// bundled, so a surviving bare specifier means an alias missed and
// `external` caught it — the loud failure the config prefers, but still a
// failure, and better found here than by a browser refusing to load.
// 2. **Did a shared dependency get bundled?** React's own source has
// fingerprints that no module of ours would contain by accident. Finding
// one means the chunk carries a second React, which is the silent version
// of the same mistake and the one worth the fingerprint check.
//
// Run after `npm run build`, in CI, on the artifact that ships.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
if (!fs.existsSync(CHUNK)) {
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
}
const chunk = fs.readFileSync(CHUNK, 'utf8')
const problems = []
// Static and dynamic imports that survived into the output. A relative or
// absolute specifier is a chunk that was split, which this build does not do —
// `lib` mode with one entry emits one file — so anything here is a bare name.
const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g
const bare = new Set()
for (const [, specifier] of chunk.matchAll(IMPORTS)) {
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
}
if (bare.size) {
problems.push(
`the chunk still imports ${[...bare].map((s) => `"${s}"`).join(', ')}` +
'nothing can resolve a bare specifier in the browser without an import map, ' +
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
)
}
// Fingerprints from the shared libraries' own source. Each is a string those
// packages ship and this module has no other reason to contain.
const BUNDLED = [
{ what: 'react', probe: 'react.development.js' },
{ what: 'react', probe: 'Invalid hook call' },
{ what: 'react-dom', probe: 'react-dom.development.js' },
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
]
for (const { what, probe } of BUNDLED) {
if (chunk.includes(probe)) {
problems.push(
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
'There is exactly one React in the page and core owns it — a second copy ' +
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
)
}
}
if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`)
process.exit(1)
}
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)

82
client/src/entry.jsx Normal file
View File

@@ -0,0 +1,82 @@
// ── module-uo's client entry point ─────────────────────────────────────────
//
// This file is the whole of the chunk's top-level behaviour: core injects
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
// registers what it has, and core renders it. The normative contract is
// MODULE_API.md §3.3.
//
// **Registration is synchronous and happens at evaluation time.** Module scripts
// are deferred, so this runs after core's bundle — which is where `window.__rg`
// is published — and before DOMContentLoaded, which is what core waits for
// before its first render. There is no subscription and no late registration: a
// module that registered asynchronously would register after the routes had been
// read, and the symptom is a page that redirects home with nothing logged. That
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
// can see it, which is why §7.7's browser smoke exists.
//
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
// on purpose. What it proves is the delivery path itself, and the imports below
// are how it proves the hardest part of it.
// These four specifiers are the whole shared-dependency contract, written the
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
// React, core's renderer and core's router, and no second copy of any of them.
// A module author writes these imports exactly as they would in any app.
//
// They are here in slice 0 rather than arriving with the first page because an
// unexercised alias is an unproven one: with nothing importing `react`, the
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
// this whole slice exists to prove has not been touched.
import { createElement, isValidElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Link } from 'react-router-dom'
const rg = window.__rg
// A module that cannot see the global is a module core did not load — which
// means the injection or the ordering broke, not the module. Say so, once,
// rather than throwing a TypeError about a property of undefined three frames
// deep in a component.
if (!rg) {
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
} else {
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
// easiest of the four to get wrong and the hardest to notice: Vite's
// object-form alias prefix-matches, so a `react` key silently captures
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
// function` in whichever component happens to render first.
const probe = <span>module-uo</span>
// The self-check: are the bindings this chunk imported the SAME objects core
// published? Identity is the only question worth asking. A bundled second
// React satisfies every type check, renders its first element happily, and
// then throws about an invalid hook call somewhere unrelated.
const shared = [
['react', createElement === rg.react.createElement],
['react/jsx-runtime', isValidElement(probe)],
['react-dom/client', createRoot === rg.reactDom.createRoot],
['react-router-dom', Link === rg.router.Link],
]
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
if (bundled.length) {
console.error(
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
)
} else {
// Registrations land here, slice by slice:
//
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
//
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
// before this file is ever served, so there is nothing to re-check here. It
// is logged because a mismatch between the core that validated the manifest
// and the core that published this global would otherwise be invisible from
// the browser, which is where the client half actually fails.
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
}
}

View File

@@ -0,0 +1,14 @@
// `react/jsx-runtime`, from core.
//
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
// under the automatic runtime, which is the default the tooling assumes. Those
// have to resolve to CORE's React like every other import — a second jsx runtime
// bound to a second React is the same one-React violation as bundling `react`
// itself, only harder to see, because it shows up as a hook dispatcher error in
// a component that looks fine.
const jsxRuntime = window.__rg.jsxRuntime
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
export default jsxRuntime.default ?? jsxRuntime

12
client/src/shim/react-dom.js vendored Normal file
View File

@@ -0,0 +1,12 @@
// `react-dom/client`, from core.
//
// A module never calls `createRoot` — core owns the root and the module renders
// inside it. This exists because a transitive import can still reach for
// react-dom, and one that resolved to a bundled copy would put a second
// renderer in the page.
const reactDom = window.__rg.reactDom
export default reactDom.default ?? reactDom
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom

30
client/src/shim/react-router-dom.js vendored Normal file
View File

@@ -0,0 +1,30 @@
// `react-router-dom`, from core.
//
// The sharpest of the four, because router state is not just a library — it is
// one live navigation context. A module with its own copy would get a router
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
// instead of the SPA, on a page that otherwise renders perfectly.
const router = window.__rg.router
export default router.default ?? router
export const {
BrowserRouter,
Link,
NavLink,
Navigate,
Outlet,
Route,
Routes,
createSearchParams,
generatePath,
matchPath,
useLocation,
useMatch,
useNavigate,
useOutletContext,
useParams,
useResolvedPath,
useSearchParams,
} = router

48
client/src/shim/react.js vendored Normal file
View File

@@ -0,0 +1,48 @@
// The shared React, taken from core rather than bundled.
//
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
// chunk, which the browser cannot resolve without an import map — and an import
// map has to be an inline `<script type="importmap">`, which core's
// `script-src 'self'` forbids. `output.globals` does not help either; it is
// iife/umd only, and this is an ES module. So each shared dependency is aliased
// to a two-line module that re-exports from the global core published before any
// module chunk evaluated.
//
// The named re-exports are not decoration: `import { useState } from 'react'`
// compiles to a named import, and a module with only a default export would fail
// at link time in the browser with a message about the binding, not about this.
const react = window.__rg.react
export default react.default ?? react
export const {
Children,
Component,
Fragment,
StrictMode,
Suspense,
cloneElement,
createContext,
createElement,
forwardRef,
isValidElement,
lazy,
memo,
useCallback,
useContext,
useDebugValue,
useDeferredValue,
useEffect,
useId,
useImperativeHandle,
useInsertionEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
useTransition,
} = react

106
client/test/build.test.js Normal file
View File

@@ -0,0 +1,106 @@
// What can be checked about the client half without a browser.
//
// Not much, and being honest about that is the point: the client half's real
// failures are timing and resolution, and neither has a shape a DOM-less test
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
// proves this half works, and it is re-run whenever this seam changes.
//
// What IS testable here is the configuration that decides resolution — and one
// of these tests exists because the trap it guards cost the Phase 1 spike real
// time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
// array form cannot. That is a property of the config, and a test can hold it.
import test from 'node:test'
import assert from 'node:assert'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CLIENT = path.resolve(HERE, '..')
const configModule = await import('../vite.config.js')
const config = configModule.default
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
test('every alias is an anchored regexp, never a bare prefix string', () => {
const aliases = config.resolve.alias
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
for (const { find } of aliases) {
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
}
})
test('react and react/jsx-runtime resolve to different shims', () => {
// The exact collision the object form causes. Asserted on the outcome rather
// than on the config's shape, so it keeps holding however the config is
// rewritten.
const resolve = (specifier) =>
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
assert.ok(resolve('react'))
assert.ok(resolve('react/jsx-runtime'))
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
})
test('every shared dependency is aliased', () => {
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
assert.ok(
config.resolve.alias.some(({ find }) => find.test(specifier)),
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
)
}
})
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
// listed in both is marked external and never aliased. The chunk then ships
// bare `import 'react'`, which no browser can resolve without an import map
// and CSP forbids one. §3.6 shows both; they do not compose.
assert.deepStrictEqual(config.build.rollupOptions.external, [])
})
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
// The direction of this dependency is the finding. Deriving the forbidden
// package list FROM the alias list means deleting an alias also deletes the
// guard against what that alias prevented — which is precisely when the guard
// is needed. So the guard states the contract, and this asserts the aliases
// stay inside it.
const packages = new Set(guardedPackages)
for (const { specifier } of SHARED) {
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
}
})
test('every alias points at a shim file that exists', () => {
for (const { find, replacement } of config.resolve.alias) {
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
}
})
test('the build emits one unhashed entry.js, which is what module.json names', () => {
assert.deepStrictEqual(config.build.lib.formats, ['es'])
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
assert.strictEqual(config.build.outDir, 'dist')
})
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
assert.strictEqual(config.build.modulePreload.polyfill, false)
})
test('every shim reads from window.__rg and imports nothing', () => {
const dir = path.join(CLIENT, 'src', 'shim')
const shims = fs.readdirSync(dir)
assert.ok(shims.length >= 4)
for (const file of shims) {
const source = fs.readFileSync(path.join(dir, file), 'utf8')
assert.match(source, /window\.__rg/, `${file} does not read the global`)
// A shim that imported anything would be a shim with a dependency to
// resolve, which is the problem it exists to remove.
assert.doesNotMatch(source, /^\s*import\s/m, `${file} imports something`)
}
})

136
client/vite.config.js Normal file
View File

@@ -0,0 +1,136 @@
// ── The client half's library build ────────────────────────────────────────
//
// Produces `dist/entry.js`: one prebuilt ES module that core injects as a
// same-origin `<script type="module" src>` before `</body>`. The operator never
// builds anything (MODULE_SYSTEM.md §1.14), so this config is not a developer
// convenience — it is how the artifact that ships is made, and CI runs it.
//
// The normative contract is MODULE_API.md §3.6. Three mechanical details in here
// were each found the hard way and are worth reading before changing anything.
//
// **1. `resolve.alias` uses the ARRAY form with anchored regexes.** Vite's object
// form does PREFIX matching, so a `react` key also rewrites `react/jsx-runtime`
// — silently, to the wrong shim, and the chunk then fails at its first element
// with a message about `jsx` not being a function. `^react$` and
// `^react/jsx-runtime$` cannot collide.
//
// **2. The aliases replace `external`; they do not accompany it.** §3.6 shows
// both, and they do not compose: Rollup asks `external` BEFORE Vite's alias
// resolver runs, so a specifier listed there is marked external and never
// aliased. The chunk then ships bare `import 'react'` specifiers, which the
// browser cannot resolve without an import map — and core's `script-src 'self'`
// forbids the inline script an import map has to be. (`output.globals` would
// have covered iife/umd and does nothing for an ES module.) Slice 0 shipped with
// both, built cleanly, and emitted exactly that chunk; `scripts/checkExternals.js`
// is what caught it. So: alias only, and nothing in `external`.
//
// **3. What `external` was there to guard is guarded by `assertSharedNotBundled`
// below.** The risk it was covering is real — an alias that misses means a
// second React welded into the chunk, which loads fine and then throws about an
// invalid hook call somewhere unrelated. A resolution-time assertion catches
// that precisely, at build time, instead of by looking for fingerprints in
// minified output afterwards.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
const shim = (name) => fileURLToPath(new URL(`./src/shim/${name}.js`, import.meta.url))
// The shared dependencies, in one place: what a module must never bundle, and
// the shim it is aliased to instead. Adding to this list means adding to
// `window.__rg` in core, which is a MODULE_API minor bump — not a decision this
// file can make on its own.
export const SHARED = [
{ specifier: 'react', shim: 'react' },
{ specifier: 'react/jsx-runtime', shim: 'jsx-runtime' },
// A production `vite build` emits the non-dev runtime, but the plugin picks
// per mode and a `--mode development` build would reach for this one. Aliased
// rather than left to chance: the shim re-exports `jsxDEV` too.
{ specifier: 'react/jsx-dev-runtime', shim: 'jsx-runtime' },
{ specifier: 'react-dom', shim: 'react-dom' },
{ specifier: 'react-dom/client', shim: 'react-dom' },
{ specifier: 'react-router-dom', shim: 'react-router-dom' },
]
// The packages whose real source must never end up in the chunk.
//
// Stated independently of SHARED, and that is the whole point — an earlier
// version derived this from the alias list "so the two cannot disagree", which
// meant deleting an alias also deleted the guard against the thing that alias
// prevented. The guard then reported nothing on a chunk with react-router welded
// into it. What may not be bundled is a fact about core's `window.__rg`, not a
// function of what this config happens to alias; `test/build.test.js` asserts
// every SHARED specifier is covered here, which is the direction the dependency
// belongs in.
//
// `react-router` and `@remix-run/router` are react-router-dom's own internals.
// They cannot appear while the alias holds — nothing resolves through to them —
// so naming them costs nothing and closes the case where a module imports one
// directly and gets a second navigation context in a page that otherwise works.
export const SHARED_PACKAGES = ['react', 'react-dom', 'react-router-dom', 'react-router', '@remix-run/router']
/**
* Fail the build if a shared dependency's real source is about to be bundled.
*
* This is the safety net, and it is a resolution-time one on purpose. The
* alternative — grepping the built chunk for a fingerprint — has to guess at
* strings that survive minification, and guesses at that are how a check ends up
* passing on a chunk that carries a second React. Here there is nothing to
* guess: if a module id resolved into `node_modules/react`, an alias missed, and
* the alias that missed is named in the error.
*
* It hooks `transform` rather than `load`, and that is not interchangeable:
* `load` is FIRST-WINS, so an earlier plugin returning the module's contents
* means this hook is never called for it. Written against `load` this guard sat
* in the build doing nothing, and a deliberately-broken alias produced a 24 kB
* chunk with react-router welded into it and a green build — which is the exact
* failure it exists to prevent. `transform` runs for every module, every time.
*/
function assertSharedNotBundled() {
return {
name: 'module-uo:assert-shared-not-bundled',
enforce: 'post',
transform(code, id) {
const normalised = id.split('\\').join('/')
const hit = SHARED_PACKAGES.find((pkg) => normalised.includes(`/node_modules/${pkg}/`))
if (hit) {
this.error(
`"${hit}" resolved into node_modules (${normalised}). It must be aliased to a shim that ` +
're-exports from window.__rg — there is exactly one React in the page and core owns it ' +
'(MODULE_API.md §3.2, §3.6). Check resolve.alias in vite.config.js.',
)
}
return null
},
}
}
export default defineConfig({
plugins: [react(), assertSharedNotBundled()],
resolve: {
alias: SHARED.map(({ specifier, shim: name }) => ({
find: new RegExp(`^${specifier.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}$`),
replacement: shim(name),
})),
},
build: {
lib: {
entry: fileURLToPath(new URL('./src/entry.jsx', import.meta.url)),
formats: ['es'],
// Unhashed, deliberately: `module.json` names this file, and a hashed name
// would have to be discovered at runtime. Core answers the cache question
// instead, serving it `no-cache` so a revalidation catches a new build
// (MODULE_API.md §3.1).
fileName: () => 'entry.js',
},
outDir: 'dist',
emptyOutDir: true,
// No inline bootstrap, for the same reason core disables it: an inline
// script is refused under `script-src 'self'`, and the failure is a chunk
// that never evaluates with a CSP report as the only clue.
modulePreload: { polyfill: false },
// `rollupOptions.external` is deliberately EMPTY — see note 2 at the top.
rollupOptions: { external: [] },
},
})

8
module.json Normal file
View File

@@ -0,0 +1,8 @@
{
"id": "uo",
"name": "Ultima Online",
"version": "0.1.0",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }
}

51
server/index.js Normal file
View File

@@ -0,0 +1,51 @@
// ── module-uo's server entry point ─────────────────────────────────────────
//
// Core requires this file once, synchronously, while `app.js` is still being
// required, and calls the exported function with `(ctx, api)`. The normative
// contract is docs/website/MODULE_API.md §2.2; the three rules that shape every
// line below are worth restating where they will be read:
//
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
// at a dead port, so a module that queried at registration time would hang
// both. Anything needing a live database belongs in `onBoot`.
// 2. **Never resolve what core owns.** This module lives at
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
// reaches core's `node_modules` and `require('express')` fails outright.
// express and express-validator arrive on `ctx`; so do the database, the
// logger, the middleware and the rest of §2.3.
// 3. **Never reach into core's tree.** No relative path may escape this
// module's root. `scripts/checkImports.js` enforces that in CI (§5.1)
// rather than leaving it to review.
//
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) deliberately
// registers NOTHING. The bundle exists, core discovers it, validates it, mounts
// its zero routes, serves its client chunk and reports it `started` — which is
// the whole delivery path proved end to end before a single UO file moves into
// it. Slice 1 brings the atlas; every slice after that adds registrations here
// and deletes the matching files from core.
/**
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
* @param {object} api what the module registers (§2.4)
*/
module.exports = function register(ctx, api) {
const log = ctx.log()
// Registrations land here, slice by slice:
//
// api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
// api.registerExtension('admin.users.detail', usersShardRouter)
// api.registerNotificationStreams(streams)
// api.registerAnnounceLeg({ leg: 'towncrier', ... })
// api.onBoot(async (ctx) => { ... })
// api.onShutdown(async () => { ... })
//
// `api` is referenced by this log line and nothing else yet, on purpose: an
// entry point that took `api` and never named it would read like an oversight
// rather than a stage of the extraction.
log.info('registered', {
version: require('../module.json').version,
registers: Object.keys(api).length,
})
}

932
server/package-lock.json generated Normal file
View File

@@ -0,0 +1,932 @@
{
"name": "module-uo-server",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "module-uo-server",
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"dev": true,
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
"integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash": "^4.18.1",
"validator": "~13.15.23"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"dev": true,
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"dev": true,
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

20
server/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "module-uo-server",
"version": "0.1.0",
"private": true,
"description": "Server half of module-uo — routers, models and the schema fragment core loads at boot",
"license": "GPL-3.0-or-later",
"main": "index.js",
"scripts": {
"test": "node --test",
"check:imports": "node scripts/checkImports.js"
},
"engines": {
"node": ">=20"
},
"//dependencies": "Deliberately none. Everything the shipped server half needs arrives on ctx (MODULE_API.md §2.3) — a module lives outside core's server/ and cannot resolve core's node_modules. The two below are devDependencies because test/_fakes.js builds a REAL express router: a fake Router would test the fake.",
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.2.0"
}
}

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
// ── §5.1 — zero internal imports ───────────────────────────────────────────
//
// The acceptance test for the whole module contract. A module that reaches into
// core's tree still works — right up until core moves a file — and the boundary
// this workstream exists to build is worth exactly as much as this check is.
//
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
// the violation but not the rule, and the difference matters in both directions:
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
// nested file (which it catches by accident) and false-alarms on a legitimate
// `require('../module.json')` from `server/` (which it catches wrongly). So this
// RESOLVES each specifier against the file that wrote it and asks whether the
// result is still inside the module root — the actual rule, stated once.
//
// Bare specifiers are checked too, and against a stricter list than "is it
// installed": core hands the module express, express-validator, the database and
// the logger on `ctx` precisely so the module never resolves them, and Node's
// resolver cannot reach core's `node_modules` from here anyway. A bare
// `require` that is not a Node builtin is therefore a module that will fail to
// load on a real install, with a message about a missing package rather than
// about the rule it broke.
//
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
// never run inside core's process — the fakes in `test/_fakes.js` build a real
// `express` router precisely so the module's routers are exercised for real —
// so they may use devDependencies. The containment check applies everywhere,
// because a test that reaches into core's tree is a test that passes on this
// machine and nowhere else.
//
// Run over the SERVER half. The client half's equivalents are its Vite build,
// which fails if a shared dependency resolves into node_modules, and
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
// specifier survived.
const fs = require('fs')
const path = require('path')
// Node's own answer, not a list reconstructed from `builtinModules`. That list
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
// `node:test` passed locally and failed in CI on the very first run — reported
// as the module boundary being broken, which it was not. `isBuiltin` is the
// authoritative check and handles the `node:` prefix itself.
const { isBuiltin } = require('module')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
// Dependencies this half is allowed to resolve for itself. Empty, and that is
// the design: everything the server half needs comes from `ctx` (§2.3). A new
// entry here is a real decision — it becomes a package an operator's install
// has to carry — so it should be argued for in a PR, not added in passing.
const ALLOWED_PACKAGES = new Set([])
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
// Directories whose contents never run inside core's process, and may therefore
// resolve this package's devDependencies.
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
const devDependencies = new Set(
Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).devDependencies || {}),
)
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
// need a dependency, and a specifier this pattern misses is a specifier written
// to be missed, which review catches and a stricter regexp would not.
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
/**
* Blank out comments and template literals before scanning.
*
* Not a nicety — without it this file fails on ITSELF, because the comments
* above name `require('../../../../etc/passwd')` as an example of what to
* catch, and index.js explains in prose why it must never `require('express')`.
* A boundary check that cannot survive being described is a check people stop
* writing comments around.
*
* A character walk rather than a regexp, because the two get in each other's
* way: `'https://x'` contains a line-comment opener inside a string, and
* `// don't` contains a quote inside a comment. Tracking the state is shorter
* than the regexp that would almost handle it. Content is replaced with spaces
* rather than removed so nothing else has to care.
*/
function stripCommentsAndTemplates(src) {
let out = ''
let i = 0
const keep = (n) => { out += src.slice(i, i + n); i += n }
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
while (i < src.length) {
const two = src.slice(i, i + 2)
if (two === '//') {
const nl = src.indexOf('\n', i)
blank(nl === -1 ? src.length : nl)
} else if (two === '/*') {
const end = src.indexOf('*/', i + 2)
blank(end === -1 ? src.length : end + 2)
} else if (src[i] === '"' || src[i] === "'") {
// Strings are KEPT — they are where the specifiers live.
const quote = src[i]
keep(1)
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
keep(1)
} else if (src[i] === '`') {
// Template literals are blanked: nothing may `require` a template, and a
// template holding SQL or HTML is a rich source of false positives.
i += 1
out += ' '
while (i < src.length && src[i] !== '`') {
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
}
i += 1
out += ' '
} else {
keep(1)
}
}
return out
}
function* walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
yield path.join(dir, entry.name)
}
}
}
/**
* Every boundary violation under `root`, resolved against `moduleRoot`.
*
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
* has never been shown to fail is a check nobody knows the state of — and this
* one guards the acceptance criterion for the whole contract.
*/
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDependencies } = {}) {
const violations = []
for (const file of walk(root)) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(SPECIFIER)) {
if (specifier.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), specifier)
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
violations.push({ file, specifier, why: 'escapes the module root' })
}
} else if (path.isAbsolute(specifier)) {
violations.push({ file, specifier, why: 'absolute path' })
} else {
const pkg = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0]
const allowed = ALLOWED_PACKAGES.has(pkg) || (!shipped(file) && dev.has(pkg))
// The `node:` prefix can only ever name a builtin, so it never reaches
// node_modules and is safe whatever this Node version enumerates.
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
if (!builtin && !allowed) {
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
}
}
}
}
return violations
}
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
// Required by a test, or run as the check? Only the second one exits.
if (require.main !== module) return
const violations = scan(SERVER_ROOT)
if (violations.length) {
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
for (const v of violations) {
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
}
console.error('')
process.exit(1)
}
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)

103
server/test/_fakes.js Normal file
View File

@@ -0,0 +1,103 @@
// Test doubles for what core hands the module.
//
// The module's server half is testable WITHOUT core, and that is not a
// convenience — it is the contract holding. Everything the module may touch
// arrives on `ctx` (MODULE_API.md §2.3), so a `ctx` this file can build is a
// complete statement of the module's dependencies. If a test ever needs
// something that is not here, either the module reached past the boundary or
// §2.3 needs a member; both are worth stopping for.
//
// `fakeCtx` mirrors §2.3 member for member, including the freezing, so a module
// that assigns to `ctx.something` fails here the way it would in core.
const express = require('express')
/** Records every call, so a test can assert what a module asked for. */
function spy(returns) {
const fn = (...args) => {
fn.calls.push(args)
return typeof returns === 'function' ? returns(...args) : returns
}
fn.calls = []
return fn
}
function fakeLog() {
const log = { error: spy(), warn: spy(), info: spy(), debug: spy() }
return log
}
function fakeCtx(overrides = {}) {
const logs = []
const ctx = {
moduleId: 'uo',
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
express,
validator: require('express-validator'),
db: { query: spy(Promise.resolve([])), pool: {} },
log: (namespace) => {
const log = fakeLog()
logs.push({ namespace, log })
return log
},
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
auth: { getUserFromRequest: spy(null) },
push: { publish: spy(Promise.resolve()) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
},
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
...overrides,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
// by the loop below and every `log.info` call would throw on push — which is
// how this was found. Keeping it off the enumeration also makes the fake more
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
// a test put there.
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
/**
* The registration api, recording rather than mounting.
*
* Copies core's `once()` rule (§2.4: "calling twice is an error") because a
* module that registers the same thing twice must fail in its own test suite
* and not first on an operator's install.
*/
function fakeApi() {
const record = {
routes: null,
extensions: [],
streams: null,
legs: [],
hooks: {},
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
called.add(name)
}
const api = {
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
api.record = record
return api
}
module.exports = { fakeCtx, fakeApi, spy }

View File

@@ -0,0 +1,149 @@
// The boundary check, checked.
//
// `scripts/checkImports.js` is the acceptance test for the whole module contract
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
// nobody knows the state of. These point it at fixtures that break each rule and
// assert it says so — and at prose that merely *describes* breaking them, which
// is what it got wrong the first time it was run.
//
// **Every fixture is a template literal, and that is load-bearing.** The scanner
// reads the files in this directory too, so an ordinary quoted string holding
// `require('../../x')` would make this file fail the very check it is testing.
// Templates are blanked by the stripper for exactly this class of text: source
// being composed as data is not source being imported.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
/** Write `files` into a throwaway module tree and scan it. */
function scanFixture(files, { dev = new Set() } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-uo-'))
const src = path.join(root, 'server')
for (const [name, source] of Object.entries(files)) {
const file = path.join(src, name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, source)
}
try {
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
}
test('the real server half is clean', () => {
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
})
test('catches a relative path that escapes the module root', () => {
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
assert.strictEqual(found.length, 1)
assert.strictEqual(found[0].why, 'escapes the module root')
})
test('allows a relative path that stays inside it, however deep', () => {
assert.deepStrictEqual(
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
[],
)
})
test('catches an absolute path', () => {
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
assert.strictEqual(found[0].why, 'absolute path')
})
test('catches a bare specifier in shipped code, even a devDependency', () => {
// The rule that makes the boundary real: express arrives on ctx. A shipped
// file requiring it would fail on a real install, because a module lives
// outside core's server/ and never reaches core's node_modules.
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
assert.strictEqual(found.length, 1)
assert.match(found[0].why, /should this come from ctx/)
})
test('allows a devDependency in test code, which never runs inside core', () => {
assert.deepStrictEqual(
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
[],
)
})
test('allows node builtins anywhere, with or without the node: prefix', () => {
assert.deepStrictEqual(
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
[],
)
})
test('allows node:test, which older Node versions omit from builtinModules', () => {
// The first CI run failed on exactly this and on nothing else: `builtinModules`
// omits `test` on Node 20 and includes it on Node 24, so every test file in
// this suite was reported as breaking the module boundary. The check asks
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
// prefix as sufficient on its own — a prefixed specifier can never resolve to
// a package, whatever the running version enumerates.
assert.deepStrictEqual(
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
[],
)
})
test('catches ESM and dynamic forms, not only require()', () => {
const found = scanFixture({
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
})
assert.strictEqual(found.length, 2)
})
test('ignores a violation that is only DESCRIBED in a comment', () => {
// The first run of this check failed on its own documentation, and on
// index.js's comment explaining why the module must never require('express').
// Prose about the rule must not trip the rule.
assert.deepStrictEqual(
scanFixture({
'a.js': [
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
`/* Nor import express from "express": core hands it over on ctx. */`,
`const path = require('path')`,
].join('\n'),
}),
[],
)
})
test('ignores a specifier-shaped string inside a template literal', () => {
assert.deepStrictEqual(
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
[],
)
})
test('a comment opener inside a string does not swallow the rest of the file', () => {
// The reason this is a character walk and not a regexp: a URL in a string
// contains `//`, and treating that as a comment would blank everything after
// it — turning the check into one that silently passes.
const found = scanFixture({
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
})
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
})
test('a quote inside a comment does not swallow the rest of the file', () => {
const found = scanFixture({
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
})
assert.strictEqual(found.length, 1)
})
test('stripping preserves line numbers', () => {
// Blanked rather than removed, so anything that later reports a line still
// reports the right one.
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
})

71
server/test/entry.test.js Normal file
View File

@@ -0,0 +1,71 @@
// The entry point's contract with core (MODULE_API.md §2.2).
//
// Slice 0 registers nothing, so there is very little behaviour to assert — and
// the rules that DO apply are the ones that would otherwise be discovered on an
// operator's install: registering synchronously, never awaiting, never touching
// a database, never mutating what it was handed. Those hold for every slice
// after this one too, which is why they are tested against the entry point
// rather than against whatever it happens to register today.
const test = require('node:test')
const assert = require('node:assert')
const register = require('../index')
const { fakeCtx, fakeApi } = require('./_fakes')
test('exports a single register function', () => {
assert.strictEqual(typeof register, 'function')
})
test('registers synchronously and returns nothing to await', () => {
const result = register(fakeCtx(), fakeApi())
// Not `assert.strictEqual(result, undefined)` alone: a module that returned a
// promise would be a module whose registration core silently never waits for.
assert.ok(!result || typeof result.then !== 'function', 'register() must not return a thenable')
})
test('touches no database at registration time', () => {
const ctx = fakeCtx()
register(ctx, fakeApi())
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
})
test('registers nothing in slice 0', () => {
const api = fakeApi()
register(fakeCtx(), api)
assert.strictEqual(api.record.routes, null)
assert.strictEqual(api.record.streams, null)
assert.deepStrictEqual(api.record.extensions, [])
assert.deepStrictEqual(api.record.legs, [])
assert.deepStrictEqual(api.record.hooks, {})
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))
// Core freezes one level deep; a module that assigned to ctx would throw here
// in strict mode and fail silently outside it. Either way it must not.
assert.doesNotThrow(() => register(ctx, fakeApi()))
})
test('logs through ctx.log, never through console', () => {
const ctx = fakeCtx()
register(ctx, fakeApi())
assert.strictEqual(ctx.logs.length, 1, 'expected exactly one logger to be taken')
const { log } = ctx.logs[0]
assert.strictEqual(log.info.calls.length, 1)
assert.strictEqual(log.info.calls[0][0], 'registered')
})
test('carries no hidden state between calls', () => {
// Core calls register() exactly once, and the `once()` guard that enforces
// that lives in core's `api` — not here. What this asserts is the module's
// own half of it: registering into a second `api` produces the same result as
// the first, so nothing is memoised at file scope where a re-register would
// silently do less than it appears to.
const first = fakeApi()
const second = fakeApi()
register(fakeCtx(), first)
register(fakeCtx(), second)
assert.deepStrictEqual(second.record, first.record)
})

View File

@@ -0,0 +1,81 @@
// `module.json` is what core validates before it will load anything, and every
// rule it is checked against lives in core's loader (MODULE_API.md §2.1, §4.3).
// Restating those rules here means a manifest mistake fails in this repo's CI,
// which can say what is wrong, rather than on an install, where the symptom is a
// module that is simply absent.
//
// These are the loader's own patterns, copied deliberately rather than imported:
// this repo has no dependency on core's source, and a copy that drifts is
// exactly what the coreApi range exists to catch.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const ROOT = path.resolve(__dirname, '..', '..')
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'module.json'), 'utf8'))
const KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
])
const ID = /^[a-z][a-z0-9-]{1,31}$/
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
const TIERS = ['public', 'admin', 'player']
test('declares no key core would reject', () => {
for (const key of Object.keys(manifest)) {
assert.ok(KEYS.has(key), `unknown key "${key}" — core rejects rather than ignores it`)
}
})
test('id is "uo" and matches the directory it installs as', () => {
assert.ok(ID.test(manifest.id))
assert.strictEqual(manifest.id, 'uo')
})
test('version and coreApi are present and semver-shaped', () => {
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
assert.match(manifest.coreApi, /^[\^~]?\d+\.\d+\.\d+/)
})
test('every declared file exists', () => {
for (const key of ['server', 'schema', 'purge']) {
if (manifest[key]) {
assert.ok(fs.existsSync(path.join(ROOT, manifest[key])), `${key}: ${manifest[key]} is missing`)
}
}
})
test('a schema fragment always comes with a purge', () => {
// Core enforces this too. A module that can create tables and cannot drop them
// leaves an operator with orphaned data and no supported way to remove it.
if (manifest.schema) assert.ok(manifest.purge, 'declares schema but no purge')
})
test('client.entry is in a subdirectory, because its directory is what gets served', () => {
assert.ok(manifest.client, 'module-uo ships a client half')
const entry = manifest.client.entry
assert.match(path.basename(entry), /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/)
// The rule worth a test of its own: core serves `dirname(entry)`, so an entry
// in the module root would publish the server source and module.json.
assert.notStrictEqual(path.dirname(path.resolve(ROOT, entry)), ROOT)
assert.ok(path.resolve(ROOT, entry).startsWith(ROOT + path.sep), 'entry escapes the module root')
})
test('declared mounts are single lowercase segments in known tiers', () => {
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
assert.ok(TIERS.includes(tier), `unknown tier "${tier}"`)
for (const prefix of prefixes) assert.match(prefix, PREFIX)
}
})
test('notification stream ids and announce legs stay namespaced or grandfathered', () => {
// Nothing to check yet — slice 0 registers neither. The assertion that matters
// is that the manifest does not quietly claim capabilities the module does not
// serve, since `GET /api/v1/public/modules` publishes them to clients.
assert.deepStrictEqual(manifest.capabilities || [], [])
assert.deepStrictEqual(manifest.mounts || {}, {})
assert.deepStrictEqual(manifest.extensions || [], [])
})