docs(website): the server half does not slice, and slice 1's record #136
@@ -26,9 +26,20 @@ here extends the contract first, in this file, before the module is written agai
|
||||
Core exports a single integer-major semver string from `server/src/modules/version.js`:
|
||||
|
||||
```js
|
||||
const MODULE_API_VERSION = '1.0.0'
|
||||
const MODULE_API_VERSION = '1.1.0'
|
||||
```
|
||||
|
||||
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
|
||||
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
||||
module chunk evaluates, which is earlier than any network round trip could answer.
|
||||
|
||||
**1.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and
|
||||
`middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`.
|
||||
Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an
|
||||
admin action a module performs belongs in core's one audit log, the extension slot needs the user its
|
||||
prefix names, §2.7 forbids a module reading core's `APP_BASE_URL`, a second rate-limit store is a
|
||||
limit enforced by two counters, and core's CMS was calling a UO file directly.
|
||||
|
||||
Every `module.json` declares a `coreApi` semver **range**. The loader checks it at boot, before it
|
||||
requires a line of module code, and a mismatch fails that module loudly into `startup_failed`
|
||||
(§4.4) with the two versions in the reason. It never silently proceeds.
|
||||
@@ -128,6 +139,11 @@ module-uo does not need is on the list.
|
||||
| `ctx.uploads` | `{ upload, UPLOAD_DIR, MIME_EXT }` | `admin/imageUpload.js` | atlas art import |
|
||||
| `ctx.posts` | `{ listAll, getById, linkAnnounceJob, markAnnounced }` | `model/posts` | `newsGump.js:108`, `announceWorker.js:58` |
|
||||
| `ctx.paths.moduleRoot` | absolute path to `modules/<id>/` | loader | atlas art, cliloc files |
|
||||
| `ctx.activity.log` | `({ req, action, detail }) => Promise<void>` | `model/activity` | every admin UO controller (1.1.0) |
|
||||
| `ctx.users.getById` | `(id) => Promise<user\|null>` | `model/users` | `usersShard.controller` (1.1.0) |
|
||||
| `ctx.site.baseUrl` | getter, string with no trailing slash | `APP_BASE_URL` | `shardAnnounce` (1.1.0) |
|
||||
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
|
||||
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
|
||||
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
|
||||
|
||||
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
|
||||
@@ -159,6 +175,7 @@ api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
|
||||
api.registerExtension(slot, router)
|
||||
api.registerNotificationStreams(streams)
|
||||
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
||||
api.registerPostHook({ onSaved, onDeleted })
|
||||
api.onBoot(async (ctx) => {})
|
||||
api.onShutdown(async () => {})
|
||||
```
|
||||
@@ -240,6 +257,24 @@ next_attempt_at)` and `leg` is a stored value. The parent `status` rollup is ove
|
||||
legs — done when every leg delivered, failed when every leg gave up, partial in between; and `done`
|
||||
when a job has no legs at all, since nothing is left to deliver.
|
||||
|
||||
**`registerPostHook({ onSaved, onDeleted })`** — added in API 1.1.0. Core's CMS is the only writer of
|
||||
posts, and a module may need to mirror one somewhere core knows nothing about. `onSaved` receives
|
||||
`{ post, transition }` — the same transition `registerAnnounceLeg` fires on — and `onDeleted`
|
||||
receives `{ post, id }`. Both are optional; a registration with neither is refused, since it is a
|
||||
subscription that can never fire. One hook per registrant.
|
||||
|
||||
Every hook is awaited and none may throw past core: a subscriber's failure is logged and costs
|
||||
neither another subscriber nor the save itself. A sidecar hiccup breaking a post edit would be a
|
||||
worse bug than a stale mirror.
|
||||
|
||||
**It is deliberately not part of `registerAnnounceLeg`**, which fires on the same transition. A leg
|
||||
is a one-shot *delivery* with retry and classification; a post hook maintains idempotent *state*, has
|
||||
to run on delete as well as save, and refreshes silently on an edit. Overloading the leg would have
|
||||
meant a `dispatch` that must not be retried and a `classify` that means nothing.
|
||||
|
||||
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
|
||||
naming a UO file, and the last thing binding core to the module.
|
||||
|
||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||
|
||||
### 2.5 Lifecycle
|
||||
|
||||
@@ -868,6 +868,66 @@ regexp (a URL in a string contains a comment opener; a comment contains quotes)
|
||||
test suite. The same applies to slice 4's §5.2 grep, which will be read by a codebase that discusses
|
||||
modules constantly.
|
||||
|
||||
#### Slice 1 — the whole server half (Module-uo#3 + website#137, 2026-08-11)
|
||||
|
||||
40 files, ~9,674 lines, 27 of 68 tables, 25 of 82 test files. Core no longer contains anything that
|
||||
knows what a shard is. Three commits per repo, readable in order.
|
||||
|
||||
**The acceptance criterion held exactly.** Core's `routes.manifest.json` goes 228 → 158 public
|
||||
routes, and the 70 that left reappear byte-identical once the module is loaded — proved by generating
|
||||
the manifest against core+module and diffing it against the pre-extraction file: zero missing, zero
|
||||
added, and `routes.guards.json` identical across all 228, so no auth gate moved either.
|
||||
|
||||
**`server/core.js` is the port mechanism and the shape is the finding.** Ported code requires its
|
||||
dependencies at file scope, which runs before `register()` and therefore before any `ctx` exists — so
|
||||
every member of that file is a stable function resolving `ctx` when *called*, and nothing may be
|
||||
destructured off `ctx` at init either, because core is free to hand over a getter. That kept the port
|
||||
to a one-line import change per file instead of a signature change per function. Its consequence:
|
||||
**require order is load-bearing.** A router does `const express = core.express` at its own file
|
||||
scope, so `core.init(ctx)` must run before the first `require` under `router/`, and the module's
|
||||
entry point requires its routers inside `register()` for exactly that reason.
|
||||
|
||||
**The contract grew to 1.1.0**, four members, none of which could be avoided:
|
||||
`ctx.activity.log` (an admin action a module performs belongs in core's *one* audit log — a module
|
||||
with its own is a second place to look, which means a place nobody looks), `ctx.users.getById`,
|
||||
`ctx.site.baseUrl`, and `ctx.middleware.rateLimit` + `accountChangeLimiter`. The rate-limit split is
|
||||
worth restating: a module states its own window and cap because it knows what its endpoints cost, and
|
||||
takes the plumbing from core so there is one `express-rate-limit` in the process and one place a
|
||||
breach is logged.
|
||||
|
||||
**`registerPostHook` is the fourth registry and the last coupling removed** — see
|
||||
[API §2.4](MODULE_API.md#24-api--what-the-module-registers).
|
||||
|
||||
**What was vendored, and what deliberately was not.** `deriveExcerpt` came across as nine lines of
|
||||
pure text handling; core's **sanitiser** sitting beside it did not, because a second copy of a
|
||||
security control diverges silently the moment either is fixed. That is the line: pure leaf helpers
|
||||
may be copied, controls may not.
|
||||
|
||||
**Two defects the extraction exposed, both in core.** The loader matched `CREATE TABLE` against the
|
||||
**raw** fragment, so a schema file whose header says "every CREATE TABLE carries IF NOT EXISTS" was
|
||||
rejected for a prefix violation on a table called `carries` — the same class as slice 0's boundary
|
||||
check failing on its own documentation, and now fixed on both scans by reading split statements. And
|
||||
the atlas art map resolved `../../../db/data`, correct in core and pointing outside `server/` in the
|
||||
module: a path that happens to resolve is exactly what survives a green suite, because the
|
||||
absent-file branch returns `{}` and looks like the normal case. It was caught by the integration run,
|
||||
not by tests.
|
||||
|
||||
**One deliberate behaviour change.** `uoLinkSocket.start()` and the sidecar health probe used to run
|
||||
*after* the listener bound and now run before it, because `onBoot` does. `start()` returns as soon as
|
||||
the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and **not**
|
||||
awaited — an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is
|
||||
diagnostics; being up is not a precondition for serving a page.
|
||||
|
||||
**One test stayed that looked like it should move.** `playerRouteAccess.test.js` guards a real past
|
||||
bug — an admin 403'd off their own characters — through a now-module-owned URL, but the *guarantee*
|
||||
is core's: `/player/*` is role-agnostic self-service. It stays and asserts that through
|
||||
`/player/appeals`. Moving it would have left core with no test of its own tier rule, which is
|
||||
precisely what regressed once before.
|
||||
|
||||
**A note for anyone running core's suite locally: remove `modules/uo` first.** With a module
|
||||
installed the manifest tests fail correctly — core's committed manifest is core-only, and the live
|
||||
stack has the module's routes on it.
|
||||
|
||||
**One thing to know before running a module locally: the loader skips a *symlinked* module directory
|
||||
silently.** `readdirSync(…, { withFileTypes: true }).filter(e => e.isDirectory())` reports a Windows
|
||||
junction as a symlink, so a module linked rather than copied into `modules/` is simply not there,
|
||||
|
||||
Reference in New Issue
Block a user