feat/asset-bridge-p2
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 1d4cd4adae |
feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled before any code that land in core rather than in module-uo. Pairs with Module-uo#22 and docs#194. ## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff` Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone, uo.world.saved) are described as admin-audience everywhere, and the narrowest value the lattice had was `staff` -- which ceilings.js defines as admin, editor AND moderator. Ceilinging them there would have let an operator save a rule that mails the staff audit digest to every moderator in it. `admin` is the ONLY genuine refinement in the tree -- every admin is staff, which is exactly the containment every other pair of branches lacks -- so it is a child rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the new PARENT entry. **The one non-obvious consequence, and the reason for ROLE_CEILINGS.** notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was correct while `staff` was the only role-gated value, and the day `admin` arrived it would have silently published every admin-ceilinged id -- the staff audit digest, the economy thresholds -- to every player's preferences screen by name. It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry fails closed instead. An EDITOR is the viewer that tells the two rules apart, and the new tests use one. MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid under 1.7.0 is valid now and no stored value changes. ## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle `news.post` has been a declared payload contract with no caller since Phase 2, so a rule naming it could never fire. utils/newsNotify.js is the caller; announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on the same enqueueIfNeeded job id -- the single "newly published news" transition signal, not re-derived. **News push therefore stops on upgrade** until an operator enables the seeded rule. That is the org lead's decision, taken over keeping the raw call beside the emit "for one release": an exception with a deadline nobody owns, which Phase 6 already refused for Teams. The Rules screen gains a second migration notice naming news, and Phase 13's release note carries it as an upgrade step. **The seed needed its own one-shot key, and this is the trap worth recording.** `engagement_team_rules_seeded` is already stamped on every deployment that has booted since Phase 6, and the guard reads its presence -- so appending news to RULES would have seeded it on fresh installs only, and on exactly the upgrades that lose their raw push, never. One key per seed GROUP is now the rule; seedGroup() is the shared implementation and seedCoreRules() is what boot calls. Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path App.jsx does not mount. An example is what the template editor previews and test-sends with, so a wrong one is a preview that looks right and a mail that is not. It is `/site/news`, the list, which is what the Discord and town-crier announcements have always linked. 1550 tests pass (16 new), 327 client tests pass, client builds, check:modules clean -- core still names no module identifier with module-uo now registering 24 UO-named triggers. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 065bec7ad8 |
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 12ff201ed5 |
feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| b30e82cde2 |
feat(modules): install, uninstall, purge and restart (phase 4, slice 1)
The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.
Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.
Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.
archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.
That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.
`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.
Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.
And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.
Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.
Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.
Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.
The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.
Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.
Two defects this slice's own tooling caught, both of which had already been
written down as classes:
- the controller destructured runPurge at require time, capturing the
function rather than the module, which made the one dependency whose
ORDER matters the one that could not be substituted;
- two swagger annotations carried an apostrophe inside a quoted string,
dropped silently by swagger-autogen before slice 5 taught it to fail loudly.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 0c4eacfa4a |
refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| bcc96e7cfb |
feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist
Add the server side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md): - GET /.well-known/assetlinks.json at the web root, gated by the new admin setting `mobile_app_links_enabled` (default off -> 404; on-but-no-fingerprint -> 404). Emits the Digital Asset Links statement for the fixed published package (MOBILE_APP_PACKAGE) + MOBILE_APP_CERT_SHA256 fingerprint(s). - mobileSso `/start` additionally accepts this shard's own self-origin https://<host>/mobile/callback when App Links are enabled — one additive exact-match entry, derived from APP_BASE_URL/request origin, never client input; the custom-scheme allowlist is never narrowed. The settings lookup is short-circuited for non-https redirects so custom-scheme rejections stay fast. - settings.isMobileAppLinksEnabled() (fail-closed) + getPublic().mobileAppLinks; admin updateSettings validates the boolean; seed default off. Tests: test/appLinks.test.js (route gating + allowlist). Full suite 284 pass. Swagger unchanged (web-root verification file is #swagger.ignore'd). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr |
|||
| 7a08546da6 |
feat(brand): BRAND_* env scheme — instance branding without a rebuild
Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the first tenant that sets these vars rather than a special case in the code. Architecture (chosen because the app ships as a prebuilt image): - server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot, with Runic Gateway defaults. - Text/colors reach the SPA at RUNTIME through the existing public settings API (settings.model.getPublic -> SiteContext), so no client rebuild. The admin-editable site title + contact email still override BRAND_NAME/email. - SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime. - Express templates the built index.html <title>/description/OG/favicon at serve time from BRAND_* (renderIndexHtml in app.js). - Server-side consumers read brand directly: emails, TOTP issuer, API docs, boot logs, HTML error page. Bot uses it for embed color + logs. Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount (BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back to a built-in image when unset. Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*) and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/ rg_token). Production keeps its real values by pinning them in .env — see .env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity (proof the substitution works). Changing a deployed COOKIE_NAME invalidates existing sessions, so UOMysticmoon pins uomm_token. Verified: 193 server tests pass, client builds, app.js loads + templates the built index.html, brand transform injects title/description/OG/favicon. |
|||
| b925114923 |
Wiki Phase 1: categories, drafts/publish, HTML sanitization
Foundation & safety phase of the wiki upgrade (see WIKI_UPGRADE.md). Schema (additive, idempotent via ensureSchema): - new wiki_categories table; wiki_pages gains category_id, excerpt, published, published_at, sort_order, and a FULLTEXT index - migration ALTERs guarded with IF NOT EXISTS for existing databases - seed reworked into 4 sections with the 8 starter pages assigned Security: - new utils/sanitizeHtml.js (sanitize-html allowlist); wiki bodies are sanitized on every save, and the article renders through DOMPurify - strips <script>, event handlers (onerror), and javascript: URLs Backend: - public: published-only list with ?category filter + /wiki/categories - admin: extended page CRUD, PATCH publish toggle, category CRUD; drafts visible to admin, hidden from public - all writes logged to activity_log Frontend: - data-driven public wiki index (sections + real descriptions; removed hardcoded blurbs/Roman numerals) with ?category filtering - article: category breadcrumb + sanitized render - admin: Section/Status columns, draft/publish + section + excerpt in the editor, and a Manage sections modal Verified end-to-end against MariaDB 11: migration clean, XSS neutralized, drafts hidden, client builds, server boots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|||
| eef79e2403 |
Initial commit: UOMysticmoon backend (Express + MariaDB + JWT)
- Layered API (router -> controller -> model -> db), serverlinkr pattern - Public / auth / admin route groups; posts, wiki, settings, users, activity models - JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS) - Site LIVE/MAINTENANCE mode with admin preview bypass - Dual file+console logging (info/warn/error/debug) + HTTP access logs - Docker Compose (app + MariaDB), schema.sql + seed, .env.example - Verified end-to-end against MariaDB (27/27 smoke checks) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |