From f3a623108437bf0588a84551f27b1b17fd209540 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 12:11:09 -0500 Subject: [PATCH] docs(website): record slice 1, and MODULE_API 1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole server half is out: 40 files, ~9,674 lines, 27 of 68 tables. The acceptance criterion held exactly -- core's manifest goes 228 to 158 public routes and the 70 that left reappear byte-identical once the module loads, with routes.guards identical across all 228. The contract grew to 1.1.0: ctx.activity.log, ctx.users.getById, ctx.site.baseUrl, ctx.middleware.rateLimit + accountChangeLimiter, and a fourth registry, registerPostHook. Each is documented with why it could not be vendored, because that reasoning is the useful part -- an admin action a module performs belongs in core's ONE audit log, a second rate-limit store is a limit enforced by two counters, and core's CMS was calling a UO file directly. §2.7.1 gains the slice record: the core.js port mechanism and its consequence (require order is load-bearing), the vendoring line (pure leaf helpers may be copied, security controls may not), the two core defects the extraction exposed, the one deliberate behaviour change, and the one test that looked like it should move and should not. Co-Authored-By: Claude --- website/MODULE_API.md | 37 ++++++++++++++++++++++++- website/MODULE_SYSTEM.md | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 4dd2c49..6d305bd 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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//` | loader | atlas art, cliloc files | +| `ctx.activity.log` | `({ req, action, detail }) => Promise` | `model/activity` | every admin UO controller (1.1.0) | +| `ctx.users.getById` | `(id) => Promise` | `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 diff --git a/website/MODULE_SYSTEM.md b/website/MODULE_SYSTEM.md index 889f7e7..4d5563e 100644 --- a/website/MODULE_SYSTEM.md +++ b/website/MODULE_SYSTEM.md @@ -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,