diff --git a/README.md b/README.md index 9f95f88..4110166 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,12 @@ sidecar as a service, and hands you the values the website needs. | [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model | | [ARCHITECTURE.md](website/ARCHITECTURE.md) | The system diagram — how core, an installed module, the sidecar and the clients fit together | | [TEAMS.md](website/TEAMS.md) | Teams as a platform primitive: roster, forums, notifications, Discord slash commands and voice — design of record | +| [ENGAGEMENT.md](website/ENGAGEMENT.md) | The engagement system: module-declared event triggers, rules, cooldowns, templates and the email / push / in-app delivery channels — design of record | | [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec | | [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract | | [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record | | [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations | +| [UPGRADE_NOTES.md](website/UPGRADE_NOTES.md) | **Operator-facing, newest first** — the upgrades that need an operator to do something, or that change behaviour quietly enough to be discovered by accident | | [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes | @@ -62,7 +64,8 @@ particular game; a module is what makes it a site *for* one. | [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar | | [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design | | [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework | -| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`). **The current protocol** | +| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) | +| [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**; built on `edge`, not yet released | | [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) | | [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge | | [PLAN.md](link/PLAN.md) | uo-link build plan | diff --git a/android/PLAN.md b/android/PLAN.md index 3397b62..0d7102c 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -659,7 +659,7 @@ not rank). | **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) | | **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) | | Contact | everyone | `/public/contact` | -| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) | +| **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) | | **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` | | Sign in / Sign out | toggles on session | `/auth/mobile/*` | @@ -707,8 +707,10 @@ Guidelines: and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11. ### 6.3 Player self-service & game data (bearer) -- **Account** — `GET /player/account`; `PATCH /player/account/username`; - `PATCH /player/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`. +- **Account** — `GET /auth/me/account`; `PATCH /auth/me/account/username`; + `PATCH /auth/me/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`. + Role-agnostic — see §6.4. (These were `/player/account/*` when this section was written; that + surface was deleted on 2026-08-29. The app was already calling `/auth/me/*` and needed no change.) - **Game account linking** — `POST /player/shard/link` (one-time `[link` code), `POST /player/shard/account` (hybrid signup, when enabled), `GET /player/shard/accounts`. - **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`, @@ -724,13 +726,16 @@ Guidelines: art/asset work on the platform side) and is explicitly out of the first release. ### 6.4 Self-service is role-agnostic under `/auth/**` (decided) -Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same* -handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we -**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role. -The app calls these regardless of role. This is an **additive v1** -change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web -back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any -authenticated role), so there's no logic duplication. +Self-service account security lives under `/auth/me/account/*` — the canonical "me" endpoints for +every role, behind `requireAuth` (any authenticated role). The app calls these regardless of role and +never touches `/admin`. + +> **Update (2026-08-29).** When this was decided it was an *additive* change: `/player/account/*` +> (gated to `role='player'`) and `/admin/account/*` served the same `account.controller` handlers and +> stayed for web back-compat. **Those 14 routes have since been deleted** — `/auth/me/account` was +> already a strict superset of both, so the web client moved onto it and `/auth/me/*` is now the only +> self-service surface. See `website/ENGAGEMENT.md` Phase 1a. **The app required no change**: `MeApi.kt` +> was already 100% `/auth/me/account/*`. > **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately, > the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content) @@ -754,6 +759,18 @@ Mirrors the website's "degrade gracefully" invariant: loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be added later without reworking the repository layer (its typed results already isolate the UI from the data source). No `Room` dependency in the initial build. +- **Amended 2026-08-31 (engagement Phase 8): one named exception, and still no Room.** The in-app + inbox keeps an offline snapshot - `core/inbox/InboxCache`, one JSON blob in the DataStore the push + code already uses, capped at the server's own default page size. An inbox is a short, read-only, + newest-first list with a server-side cursor and no joins, so what "works offline" needs is the + newest page and the badge, not a database. **Every snapshot is scoped to (base URL, user id)** and + handed back only to that pair, which is what stops one account's notifications surfacing under + another's session on the teardown paths that never reach a logout (a dead refresh token, a server + switch); the clear-on-logout beside the push deregistration is the tidy-up, not the safeguard. + **The known limit: this serves a running app, not a cold start.** `MainActivity` gates the whole of + `RunicApp` on loading the site's appearance, so an offline launch still shows the shell's "Can't + reach the site / Retry" and never reaches the drawer. Changing that is a change to the shell's + startup model, and it was left for the org lead rather than widened into Phase 8. --- @@ -784,8 +801,9 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises. 1. **Role-agnostic self-service under `/auth/**` (§6.4, decided).** ✅ **DONE (2026-07-19, RunicGateway/website#76 (+ this docs PR)).** A `me.routes.js` sub-router mounts the existing `account.controller` self handlers behind `requireAuth` (any role) at - `/auth/me/*`, so the app has one self surface and never touches `/admin`. The old - `/player/account/*` + `/admin/account/*` routes stay for web back-compat. Shipped routes: + `/auth/me/*`, so the app has one self surface and never touches `/admin`. (The old + `/player/account/*` + `/admin/account/*` routes stayed for web back-compat at the time; they were + deleted on 2026-08-29 — see §6.4.) Shipped routes: - `GET /auth/me` — current `{ id, username, role }` (already existed; the app's role source). - `GET /auth/me/account` — full self account. - `PATCH /auth/me/account/username`, `PATCH /auth/me/account/password`. @@ -1244,6 +1262,106 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes > `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set" > `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields. +### Per-channel preferences — the superset endpoint (engagement phase 3, 2026-08-29) + +Push is no longer the only channel a preference can name. `docs/website/ENGAGEMENT.md` phase 3 added +`notification_channel_prefs` and, with it, `GET · PUT /auth/me/notifications/channels`. + +**Nothing above changed.** `/notifications/streams` and `/notifications/subscriptions` keep their +exact wire shapes, including the `{"streams":[]}` gotcha, and the shipped APK needs no update to keep +working — `notification_subscriptions` is now the **push projection** of the new table, and every +write to either fans out to the other. That was the acceptance criterion the phase was built against, +with the empty-array case tested explicitly. + +**What the new endpoint adds, for whenever the app adopts it:** + +```jsonc +// GET /auth/me/notifications/channels +{ + "channels": [ // the delivery-channel registry + { "id": "push", "label": "Push", "carriesContent": false, + "defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] }, + { "id": "email", "label": "Email", "carriesContent": true, + "defaultMode": "off", "supportsDigest": true, "modes": ["off", "instant", "digest"] }, + { "id": "inapp", "label": "On the site", "carriesContent": true, + "defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] } + ], + "items": [ // every subscribable id, streams AND triggers + { "id": "news.post", "label": "News posts", "description": "…", + "personal": false, "requiresLinkedAccount": false, "ceiling": "authenticated", + "channels": ["push", "email", "inapp"], + "modes": { "push": "instant", "email": "off", "inapp": "off" } } + ] +} +``` + +Four properties the UI should be built on rather than around: + +- **`items` is the union of streams and triggers**, one namespace. An id can be a push stream, an + event trigger with a payload contract, or both. A trigger-only id (`uo.house.idoc_warning`) carries + no `push` in its `channels` and no `push` key in `modes` — there is nothing registered to push it — + so **render the toggles from `channels`, never from a hardcoded three**. +- **`modes` is the *effective* mode, not the stored one.** Where the user has expressed nothing, the + server substitutes that channel's `defaultMode`. The client never has to know which it is looking + at, and must not re-implement the defaulting. +- **The PUT is sparse, and this is the one place it diverges from every other `/auth/me` PUT.** Send + only the pairs you changed: `{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}`. + Everything not named is left alone, so the notifications screen can save one toggle without holding + the whole table. `off` is a mode, never an omission — **so the empty-array gotcha above does not + apply here at all**: there is no "clearing the last one" case, because turning something off is a + row like any other. `prefs` is still required, so a DTO field with no default is still the right + shape. +- **Entries the server cannot accept are dropped, not refused** — an unknown id, a channel that does + not apply to that id, a `digest` on a channel that cannot batch. The response is the full stored + state, so re-render from it rather than assuming the request took. + +**One id may be missing from `items` that the app expects.** A trigger whose declared audience +`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it +would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged. + +### The inbox and the per-channel screen - as built (engagement Phase 8, 2026-08-31) + +**The drawer's "Notifications" is the INBOX now**, and the preferences are one tap away behind its +gear - the arrangement Phase 7 shipped on the web (`/account/notifications` is the content, +`.../settings` the preferences), and what a person means when they tap the word. `Routes.NOTIFICATIONS` +is unchanged and `Routes.NOTIFICATIONS_SETTINGS` is new, so an admin's nav override pointing at the +old route still lands somewhere sensible. + +**The inbox** (`ui/notifications/InboxScreen` + `InboxViewModel`) reads the four routes Phase 7 +shipped: a keyset page on `before` (never an offset - the list gains rows at the top while it is being +read), the unread count that rides along on every page, and the two mark-read writes. Reads are +optimistic and deliberately not rolled back on failure; a local read also rewrites the snapshot, or +going offline right after reading everything would bring the badge back on the next cold open. The +drawer badge has its own view model on `/notifications/unread-count`, refreshed on resume rather than +on a timer - the tickle is what says "something happened", so polling would be a second, worse copy +of push. + +**Two things the app has to do that the backend contract does not state:** + +- **Resolve the item's `url`.** Phase 7 specifies it is **relative-only** (`/guilds/.../forum/403`), + which is right for a browser already on the site and a dead link on a phone. + `InboxViewModel.linkFor` resolves it against the configured base with OkHttp's `HttpUrl.resolve`, + which absolutises the path *and* returns null for anything that would not end up http(s) - so a + `javascript:` or `intent:` url in a notification body opens nothing. The live rig is what caught + this: the first cut only opened `http(s)`-prefixed strings, so every link in the inbox did nothing + at all. +- **Route the tickle on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER + id as `stream` (ENGAGEMENT.md section 7.2's one namespace) and `PushStreams` knows only the eight + push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends + anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the + route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint + that a row exists, and the contract stays wake-and-pull. + +**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off +`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire: +one row per subscribable id, a control per channel in **that item's** `channels`, and its shape from +**that channel's** `modes` - a switch for two modes, chips for three, so email's `digest` reaches the +app and a fourth channel would too, without a release. A trigger-only id shows no push control rather +than a dead switch, and on a shard with no push relay the push controls are absent with the reason in +a note beside the list (email and on-site preferences are still worth setting there). Each change is +one sparse PUT of one pair, and the screen re-renders from the response, so an entry the server drops +shows up as the control springing back. + ## 12. Build & CI (Gitea Actions) Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 806a2f7..9657a35 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -The current version is **4**, shipped in sidecar **v2.0.0** and overlay **v1.0.0**. +The current version is **5**. It is built but **not yet released** — the last shipped pairing is protocol 4, in sidecar **v2.0.0** and overlay **v1.0.0**. -- Every response carries an **`X-UOLink-Version: 4`** header. -- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 4`. -- **Optionally**, send `X-UOLink-Version: 4` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: +- Every response carries an **`X-UOLink-Version: 5`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`. +- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json - { "error": "protocol version mismatch", "sidecar_protocol": 4, "client_protocol": "3" } + { "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" } ``` Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. @@ -82,6 +82,21 @@ the wire. Additive in the same shape as the previous two bumps — nothing that so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once it declares `4`. +**v5 (Protocol 5)** adds three things at once ([`v5.md`](v5.md)) — `house.decay` gains `ownerName` +and a `schedule`, `vendor.listing` gains `ownerAcct` and a `fees` block, and `account.login.result` +is a new kind carrying the verdict its long-standing `account.login.attempt` companion fires too +early to know. Three at once because a bump costs a release, a bundle and an operator update on +every shard, so a field left out costs a whole second round of that. + +Additive again: no existing field changed shape, and **no new endpoint** — every v5 addition rides +kinds that already existed or a kind that behaves like any other on the feed. Two consumer notes, +both about ABSENCE rather than presence, because both are easy to read as an error: + +- `schedule.estimatedCollapse` is **omitted whenever it is not exactly knowable** — which, on a + dynamic-decay shard, is every stage before IDOC. +- `fees` is omitted entirely by a pre-v5 overlay, and reduces to `{"exempt": true}` for a + commission vendor. Neither means "this vendor has no money". + **Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you @@ -168,7 +183,8 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s |------|--------| | `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) | | `mob.logout` | `who` | -| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) | +| `account.login.attempt` | `acct`, `ip` — an authentication attempt, fired from a sink that runs **before** the auth decision, so it fires on successful logins too. Use `account.login.result` for the verdict. No password ever leaves the shard | +| `account.login.result` | `acct`, `ip`, `accepted`, `reason` — **Protocol 5.** The verdict of the attempt above, which the attempt structurally cannot carry. `reason` is an `ALRReason` (`BadPass`, `Invalid`, `Blocked`, `InUse`, `BadComm`) and is **present only when `accepted` is false**, because the enum's zero value would read as a failure reason on an accept. Build "someone tried to get into your account" on THIS kind | #### Economy & commerce | kind | fields | notes | @@ -204,15 +220,36 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s #### Housing / IDOC | kind | fields | |------|--------| -| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` | +| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerName`*, `ownerAcct`, `schedule:{...}`*, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` | `from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign. +\* **Protocol 5.** `ownerName` is the owner's character name (`ownerAcct` is the game account, and +the only one of the two that identifies a person). `schedule` is a nested object: + +| field | meaning | +|---|---| +| `dynamicDecay` | whether this shard runs ServUO's dynamic decay (`Core.ML`). Always present | +| `nextStage` | ISO-8601 UTC: when the house leaves its current stage. Absent under static decay, which keeps no stage clock | +| `decayPeriodSec` | seconds from a full refresh to collapse; lets a reader turn `lastRefreshed` into a percentage | +| `estimatedCollapse` | ISO-8601 UTC — **present only when it is exact**, see below | + +**`estimatedCollapse` is absent far more often than not, and that is deliberate.** Under dynamic +decay ServUO draws each stage's duration at *random* when the stage is entered, so collapse is +exactly knowable only once the house is already at `IDOC` — at which point the next transition is +the collapse. Under static decay it is a pure function of `lastRefreshed + decayPeriodSec` and is +exact at every stage. It is omitted rather than approximated, because an absent field is honest +where a wrong date is a dated promise. **Treat its absence as "not knowable", never as "not yet +read"** — and never fall back to computing one yourself under dynamic decay. + ```json -{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly", - "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House", - "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0}, - "builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"} +{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC", + "map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace", + "ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002", + "schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z", + "decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"}, + "ban":{"x":1482,"y":1604,"z":0}, + "builtOn":"2026-06-03T14:02:44Z","lastRefreshed":"2026-08-25T17:21:14Z"} ``` #### Economy supply (periodic) @@ -523,14 +560,46 @@ shop name or location actually change. | kind | fields | notes | |------|--------|-------| -| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. | +| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `ownerAcct`*, `location{}`, `fees{}`*, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. | | `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. | +\* **Protocol 5.** `ownerAcct` is the owner's game account — `ownerName` is a character name and +identifies nobody, so this is the field that makes a shop resolvable to a person at all. + +`fees` describes ServUO's vendor dismissal rule (`PlayerVendor.PayTimer`: at each tick the charge +is compared with the funds, and the vendor is destroyed when the charge wins): + +| field | meaning | +|---|---| +| `exempt` | `true` for a commission vendor, which has no pay timer and is **never** dismissed for fees. When true, no other field is present | +| `newVendorSystem` | which of ServUO's two vendor systems is in force; it decides all three quantities below | +| `chargePerPeriod` | what is deducted at each tick | +| `funds` | gold available to pay it (`holdGold` and `bankAccount` are the raw parts) | +| `payIntervalSec` | seconds between ticks: 86400 under the new system, **one UO day (≈2 real hours)** under the old | +| `nextPayAt` | ISO-8601 UTC: the next tick | +| `periodsRemaining` | ticks survived before the one that finds the charge unpayable | +| `dismissalAt` | ISO-8601 UTC: the tick the vendor is destroyed on. **This is the field to build on** | + +**There is deliberately no `daysRemaining`**: under the old vendor system a pay period is a UO day, +so a "days" field would be wrong by a factor of twelve on exactly the shards least likely to notice. +`dismissalAt` is an instant and needs no units. It assumes no further sales or deposits — but +unlike `house.decay`'s `estimatedCollapse` there is no randomness in it: given the current funds +it is exact. + +**Treat `exempt: true` and a distant `dismissalAt` as different things.** "Never dismissed" and +"dismissed in 400 days" render differently, and conflating them is how a vendor that cannot expire +ends up in an expiry warning. + ```json {"kind":"vendor.listing","serial":"0x40001234", "shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow", + "ownerAcct":"darrow_acct", "location":{"map":"Trammel","x":1421,"y":1699,"z":0, "region":"Britain","house":"Darrow's Villa"}, + "fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548, + "funds":82504,"holdGold":82504,"bankAccount":0,"payIntervalSec":86400, + "nextPayAt":"2026-09-01T21:01:21Z","periodsRemaining":7, + "dismissalAt":"2026-09-08T21:01:21Z"}, "count":2,"total":2,"truncated":false, "items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1, "price":25000,"name":null,"cliloc":1023721}, @@ -1020,7 +1089,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's A typical character page: ```js -const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" }; +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" }; // 1. render the roster const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); diff --git a/link/PLAN.md b/link/PLAN.md index 5bf9b88..b9ebd1b 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -103,7 +103,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an | Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. | | Player offline | `EventSink.Logout` | low | Pair with Login. | | Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. | -| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. | +| Auth attempts | `AccountLogin`, `GameLogin` | low | IP signals for the website. **`AccountLogin` cannot give you a FAILED login**, which this row assumed it could: it is a veto hook that fires *before* the auth decision, and `AccountLoginEventArgs` constructs with `Accepted = true`, so a handler reading the verdict there reports every login as accepted. Protocol 5 adds `account.login.result`, read one Core slice later — [`v5.md`](v5.md) §2.3. | | Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. | | Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. | @@ -133,6 +133,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an | **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. | | Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. | | Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. | +| **Vendor fee state** | vendor sweep (Protocol 5) | periodic | The dismissal deadline, not just the balance. `PlayerVendor.PayTimer` destroys a vendor when `pay > totalGold`, and **both halves differ between ServUO's two vendor systems** — `ChargePerRealWorldDay` vs `ChargePerDay`, `HoldGold` vs `BankAccount + HoldGold`, a real day vs a UO day. Resolved on the shard into a `dismissalAt` instant; see [`v5.md`](v5.md) §2.2. `IsCommission` vendors have no pay timer at all. | | Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. | > ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.** @@ -144,6 +145,7 @@ Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is | Signal | Hook | Freq | Notes | |--------|------|:----:|-------| | Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. | +| **Decay schedule** | same sweep (Protocol 5) | with the above | `NextDecayStage`, `DecayPeriod`, and a collapse time **only where one exists** — see the note below. | Hold a `Dictionary` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot. @@ -153,6 +155,20 @@ Hold a `Dictionary` and emit only on transition. On `ServerS - **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest. - Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`. +**What that model means for publishing a collapse time (Protocol 5).** Because each stage's +duration is drawn at random *when the stage is entered*, `NextDecayStage` is exact for the next +transition and **nothing beyond it is known at all**. A collapse time is therefore exact only once +the house is already at IDOC, where the next transition is the collapse. `house.decay` omits +`estimatedCollapse` everywhere else rather than approximating it — the field's absence means "not +knowable", never "not yet read". On a shard where `DynamicDecay.Enabled` is false the dead code +above is live instead, and collapse is exact at every stage; the frame carries `dynamicDecay` so a +consumer can tell which regime it is reading. [`v5.md`](v5.md) §2.1. + +**The `CanDecay` rule above is also the main trap when TESTING this.** Forcing a stage with +`SetDynamicDecay` on an `AutoRefresh` house does nothing observable: the `DecayLevel` getter calls +`ResetDynamicDecay()` and reports `Ageless`, so the forced stage is wiped before the next sweep +reads it and **no frame is emitted at all** — which looks exactly like a broken emitter. + Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`. ### 5.5 Combat, death, PvP @@ -233,6 +249,13 @@ Three edits, then the bridge stays pure-subscription: Newline-delimited JSON, one object per line, `serial` as the primary key. +> **The frames below are 1.0's design sketch, not the shipped wire.** They have drifted in the four +> versions since — `house.decay` in particular ships a flattened `ownerSerial`/`ownerAcct`/`ownerName` +> rather than an `owner` object, and from Protocol 5 its `nextStage` lives inside a nested `schedule` +> alongside `decayPeriodSec`, `dynamicDecay` and a conditional `estimatedCollapse`. The normative +> field lists are [`INTEGRATION.md`](INTEGRATION.md) §Event catalog, with each bump's rationale in +> [`PROTOCOL_2.md`](PROTOCOL_2.md), [`v3.md`](v3.md), [`v4.md`](v4.md) and [`v5.md`](v5.md). + ### Outbound (shard → sidecar) ```jsonc diff --git a/link/v5.md b/link/v5.md new file mode 100644 index 0000000..e048fab --- /dev/null +++ b/link/v5.md @@ -0,0 +1,279 @@ +# Protocol 5 — Three enrichments, one bump + +**Status:** Built, on `edge`. Not yet cut over to `main`, so not yet released. +**Date:** 2026-08-31 +**Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`INTEGRATION.md`](INTEGRATION.md) (website API). +**Driven by** [`../website/ENGAGEMENT.md`](../website/ENGAGEMENT.md) Phase 10. + +--- + +## 1. Why three things at once + +Every previous bump did one thing. This one does three, and the reason is a property of the bump +itself rather than of the features: + +**A protocol bump costs a sidecar release, a republished bundle, and an operator update on every +shard.** A field left out does not cost a follow-up commit — it costs a *second* bump with the same +three-part lead time, and an operator population split across two protocol versions until the second +one finishes propagating. So everything the engagement workstream is known to need from the wire was +decided before any of it was written, and all of it rides v5. + +The three were not picked from a wish list. Each is a field that a **specific Phase 11 trigger cannot +be built without**, and in two of the three cases that was discovered by reading the emitters rather +than by reading the plan, which had recorded both as already possible. + +| # | Enrichment | The trigger that needs it | What was actually there | +|---|---|---|---| +| a | `house.decay` gains `ownerName` + a `schedule` | `uo.house.idoc_warning` — the flagship | The frame could say a house had *become* IDOC and nothing about when it ends | +| b | `vendor.listing` gains `ownerAcct` + `fees` | `uo.vendor.expiring` | `ownerSerial` and `ownerName` only, and **no fee data anywhere on the wire** — the plan had this as "needs a mapper" | +| c | `account.login.result`, a new kind | `uo.account.login_failed` | `account.login.attempt` fires **before the auth decision**, so it fires on successful logins too | + +### 1.1 What was deliberately left out + +**`vendor.sale` stays in the opt-in patch tier.** It is real, and it already carries `ownerAcct` — +but it lives in `servuo-plugins/patches/`, verified only against ServUO 57.4, because it needs a +`PlayerVendorSale` EventSink that the patches *add* to core. Moving it into `overlay/` was considered +and declined: the emitter cannot exist without the core event, so "moving" it means shipping a core +patch as though it were an overlay file. The consequence is a documentation obligation, not a +protocol one — `uo.vendor.sale` is a trigger that is **silently dormant on a shard that declined the +tier**, and the rule that names it has to say so. + +--- + +## 2. The shard side + +### 2.1 `house.decay` — a schedule, and only where it is knowable + +`BridgeSweeps.WriteDecay` gains `ownerName` and one nested object: + +```json +{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC", + "name":"Millrace","ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002", + "schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z", + "decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"}, + "map":"Felucca","x":1480,"y":1600,"z":0,"lastRefreshed":"2026-08-25T17:21:14.5725317Z"} +``` + +**`estimatedCollapse` is present only when it is exact**, and that is the whole design of this field. +ServUO has two decay implementations and they differ in how knowable the future is: + +- **Dynamic decay** (`DynamicDecay.Enabled`, i.e. `Core.ML`) draws each stage's duration at *random* + when the stage is entered — `BaseHouse.SetDynamicDecay` calls `DynamicDecay.GetRandomDuration`. So + `NextDecayStage` is exact for the **next** transition and nothing beyond it is known at all. + Collapse becomes exact only once the house is already at IDOC, because then the next transition + *is* the collapse. +- **Static decay** (`GetOldDecayLevel`) is a pure function of `LastRefreshed` and `DecayPeriod`, so + collapse is exact at **every** stage — there is no randomness to wait out. + +Emitting a dynamic-decay house's collapse time at, say, `Fairly` would publish a guess as a fact, and +on the website's side that becomes a dated promise in a player's mail. So it is **omitted rather than +approximated**. An absent field is honest where a wrong date is not, and the consumer's +`required: false` declaration already permits the absence. + +`dynamicDecay` is emitted unconditionally so a reader can tell the two regimes apart, rather than +having to infer them from which fields arrived. + +**The nesting is load-bearing, not cosmetic.** The website's visibility projection matches literal +JSON keys, so a nested group is one admin rule that hides the whole schedule where four sibling keys +would be four rules that drift apart — the same argument that made `vendor.listing`'s `location` +nested in v3. + +### 2.2 `vendor.listing` — an owner who can be found, and a deadline + +Two additions, and the first matters more than it looks: + +**`ownerAcct`.** The frame has carried `ownerName` since v3, but a character name joins to nothing: +the website's `shard_account_links` is keyed by the game **account**. Until now a vendor row named an +owner the site could not resolve to a person, which is why a per-owner rule was impossible however +much fee data existed. + +**`fees`**, a nested object describing `PlayerVendor.PayTimer`'s dismissal rule. That rule is +`if (pay > totalGold) Destroy()` at each tick — and *both halves of the comparison differ between +ServUO's two vendor systems*: + +| | charge | funds | interval | +|---|---|---|---| +| `NewVendorSystem` | `ChargePerRealWorldDay` | `HoldGold` | 1 real day | +| old system | `ChargePerDay` | `BankAccount + HoldGold` | 1 UO day (`Clock.MinutesPerUODay`, ≈2 real hours) | + +Both are resolved on the shard rather than left for the sidecar or the website to reconstruct, +because reconstructing them anywhere else is a second implementation of a rule that lives in +`PlayerVendor`. + +```json +"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,"funds":82504, + "holdGold":82504,"bankAccount":0,"payIntervalSec":86400, + "nextPayAt":"2026-09-01T21:01:21.6883320Z","periodsRemaining":7, + "dismissalAt":"2026-09-08T21:01:21.6883320Z"} +``` + +Two naming decisions worth recording, because the obvious spellings are both wrong: + +- **There is no `daysRemaining`.** On an old-system shard a pay period is a UO day, so a field with + "days" in its name would be off by a factor of twelve on exactly the shards least likely to notice. + The wire carries `periodsRemaining` plus the interval that gives it meaning, and resolves the + arithmetic into `dismissalAt` — an *instant*, which needs no units at all. +- **`exempt` is not "a very long time left".** A commission vendor (`IsCommission`) has no `PayTimer` + and is never dismissed for fees. It reports `{"exempt": true}` and no schedule, so a surface + rendering "never" can tell it apart from one rendering "in 400 days". + +`dismissalAt` assumes no further sales or deposits, exactly as a bank-balance projection does. Unlike +a dynamic-decay house, though, there is **no randomness in it**: given the current funds it is the +exact tick the vendor is destroyed on. + +**The market sweep has to DIFF the fee state, and originally it did not.** `BridgeMarket.Signature()` +compares shop name, owner, map, coordinates and the item/price list — the things a *listing* is made +of — so adding `fees` to the frame did not by itself make a frame arrive when the fees changed. A +vendor quietly running down its gold altered nothing the sweep compared, emitted nothing, and the +consumer that exists for exactly that event could fire only by coincidence: when somebody happened to +reprice an item on a shop that was already broke. Found on the engagement Phase 11b live walk and +fixed there (ENGAGEMENT.md, decision 13). + +The signature carries the **derived** values — `exempt` and `periodsRemaining` — not the raw ones. An +integer division moves only when the shard's own answer to "is this vendor in danger" moves, while +`holdGold` changes on every sale and `nextPayAt` on every pay tick; keying on either would re-emit a +fat listing frame for a shop whose listings had not changed. This is emit CADENCE, not frame shape: +no field was added and `PROTOCOL_VERSION` is untouched. + +**The general form is worth carrying to the next enrichment.** A sweep-based kind has a change +detector, and a field added to the frame that is not added to the detector is a field that ships +correct and arrives never. + +### 2.3 `account.login.result` — a new kind, because the old one cannot be fixed + +`account.login.attempt` (protocol 1) is emitted from `EventSink.AccountLogin`, which +`PacketHandlers.AccountLogin` invokes **before** it branches on `e.Accepted`. The emitter's own +comment has said so since it was written: *"Fires before the auth decision, so this is an attempt, +not a result."* + +The verdict is set by the handlers themselves — `Server.Misc.AccountHandler` is the one that checks +the password and sets `Accepted`/`RejectReason` — so inside our handler the verdict does not exist +yet. `AccountLoginEventArgs` constructs with `Accepted = true`, which means a naive read there +reports **every** login as accepted, including the ones about to be rejected. + +That is why "someone tried to log into your game account" could not be built on the attempt: a rule +on it would have mailed a security alert every time the player logged in successfully — the exact +inversion that makes people stop trusting security mail. + +```json +{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":false,"reason":"BadPass"} +{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":true} +``` + +**How it reads the verdict.** `Timer.DelayCall(TimeSpan.Zero, …)` over the args object: one Core +slice later, `InvokeAccountLogin` has returned and the verdict is final. This needs no core patch, +and — the reason it is preferred over simply subscribing late — it does not depend on handler +**subscription order**, which ServUO does not define and which a shard's own scripts can change. + +Three details that are deliberate: + +- **`reason` is omitted on an accept.** `ALRReason`'s zero value is `Invalid`, so emitting it + unconditionally would put a plausible-looking failure reason on every successful login. +- **The IP is resolved inside the handler**, not in the deferred call: `AccountLogin_ReplyRej` + disposes the `NetState` before the deferred read runs. +- **The password is never read, never logged, never emitted.** The args object carries it, so it is + held for one extra Core slice and no longer, and exactly two properties are read off it. + +--- + +## 3. The sidecar side + +`PROTOCOL_VERSION: u32 = 4` → `5` in `sidecar/src/main.rs`, and **nothing else**. + +There is **no store migration this time**, unlike v4. Every frame is persisted whole and the board +tables index only the columns they already had, so the new fields ride inside the stored JSON and the +new kind lands in `events` like any other. There is no kind allowlist to extend, either. + +That is the dumb-forwarder property doing its job (v3 §3): the +sidecar defines no schema for a frame's contents, so it needs no change when they grow. A bump that +touches one constant is the *expected* cost of an additive protocol version here; v4 needing a +migration was the exception, because it added a column to a board table. + +--- + +## 4. Visibility + +Three classifications, made on the website in `module-uo`'s `shardVisibility.js`, never in the +sidecar. Each had to be chosen: a v5 field nobody classified would either leak, or be silently +invisible with nobody noticing. + +| Field / kind | Audience | Why | +|---|---|---| +| `house.decay` → `schedule` | **`anonymous`**, configurable | The countdown *is* the public IDOC page's content, and a house at IDOC is already announced in game. Listed rather than left unconfigurable so a shard that considers a precise collapse time an unfair advantage can raise it | +| `vendor.listing` → `fees` | **`admin`**, configurable | The only default in the `market` feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce | +| `vendor.listing` → `ownerAcct` | **admin, locked** | Rule 1, automatically: the key ends in `acct` | +| `account.login.result` | **admin, by omission** | Rule 2 — a kind absent from `KIND_FEATURE` reaches nobody below admin | + +**Why `fees` breaks the `market` pattern.** A shop's name, owner and location are already visible to +any player through the stock in-game Vendor Search gump, which is the entire argument for publishing +them. A vendor's held gold, daily charge and dismissal date are not: in game they are visible to the +**owner**, on that vendor's own gump. Publishing them anonymously would be a genuinely new disclosure +*and* a targeting aid — it says which shops are about to be abandoned and how much coin is sitting in +each. + +**Why `account.login.result` is admin-only by omission rather than by mapping.** `KIND_FEATURE` is +the map of kinds that *may* leave the admin channel, and an admin can widen anything on it. There is +no rung below admin that a frame carrying an IP address and an auth verdict belongs on, so mapping it +at all would create a door that should not exist. The omission is the decision, and there is a test +that says so by name. + +**The REST reads are unchanged.** `shard_vendors`' new columns are not in any read model's column +list: they exist for Phase 11's server-side trigger and reach no client at all. The house schedule +*is* on the read model, re-nested under `schedule` on the way out so the stored shape and the live +wire frame spell it identically — otherwise one admin rule would cover only one of the two paths, +which is the §3.6.1 failure mode v3 already recorded once. + +--- + +## 5. Cross-repo obligations + +| Repo | Change | +|---|---| +| `servuo-plugins` | `BridgeSweeps.AppendDecaySchedule`, `BridgeMarket.AppendFees`, `BridgeEvents.EmitLoginResult` · **`overlay.toml` `protocol = 5`, in the same PR as the emitters** | +| `link` | `PROTOCOL_VERSION` → 5 | +| `module-uo` | ingest + schema (`shard_houses` ×4, `shard_vendors` ×8) · `shardVisibility.js` ×3 · the `uo_link_config` protocol pin, both declaration sites | +| `docs` | this file · `INTEGRATION.md` §Housing, §Market, §Account · `PLAN.md` §5/§7 | +| `runicgateway.com` | `platform.json.protocol` → 5 — **deferred to ENGAGEMENT.md Phase 12**, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately | + +**The pin has three declaration sites and they are checked against each other, not against a +literal.** `module-uo`'s schema test used to assert `DEFAULT 4` at each site, which is how protocol 4 +shipped with the emitters moved and one declaration left behind: every site agreed with itself. It +now reads `DEFAULT_PROTOCOL` from the model, so the assertion is "the declarations **agree**" and a +bump that misses one fails in CI instead of on an operator's install. + +--- + +## 6. Verification + +Unit tests: 470 in `module-uo/server` (16 new), 39 in the sidecar, all passing; the C# compiles +against the real ServUO 57.4 reference assemblies. + +Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles, +209k items), the **release** Rust sidecar, and `tools/scaffolding/BridgeProtocol5Probe.cs`. + +| Claim | Evidence | +|---|---| +| `estimatedCollapse` appears **only** at IDOC | One house walked Fairly → Greatly → IDOC. The `Fairly` and `Greatly` frames carry `nextStage` and no `estimatedCollapse`; the `IDOC` frame carries both, equal | +| `house.decay` carries the owner's character name | `"ownerName":"Zara Crowe"` on all three frames | +| the fee block is right | `funds/chargePerPeriod` floors to `periodsRemaining` on every observed vendor (82504/10548→7, 63252/6834→9, 115531/10035→11, 43/60→0) | +| a vendor with one tick left says so | `periodsRemaining: 0` with `dismissalAt == nextPayAt` — dismissed at the very next tick, which is the case `uo.vendor.expiring` exists for | +| the login verdict is the FINAL one | A real socket login with a wrong password → `accepted:false reason:BadPass`; with the right one → `accepted:true`, no `reason`. **Both saying `true` is the bug this kind exists to prevent** | +| the sidecar needed no change beyond the constant | `GET /health` → `"protocol":5`; every v5 field arrived through the generic forward path | +| a mismatched pair is refused, not mis-parsed | Unchanged and already tested in `installer`: `bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused` and `doctor.rs::a_protocol_mismatch_fails_the_row`. The check is version-agnostic, so v5 needs no installer change | + +### 6.1 Two rig traps, both of which fake a broken emitter + +Recorded because each cost a rebuild and each produces *silence* rather than an error. + +- **An in-process login probe can never produce `accepted:true`.** `AccountHandler` calls + `acct.HasAccess(e.State)` *before* it checks the password, and a null `NetState` fails that — so + firing `EventSink.InvokeAccountLogin` directly logs `Access denied` even for a correct password. + Only a real socket proves the accepted half (and it is the better test anyway: it also produces the + real `ip`). +- **Forcing a decay stage on a house that cannot decay emits nothing at all.** Only `Condemned` and + `ManualRefresh` houses decay; an `AutoRefresh` one — and *the owner's newest house is always + `AutoRefresh`* — has a `DecayLevel` getter that calls `ResetDynamicDecay()` and reports `Ageless`, + wiping the forced stage on the very next read. The sweep then sees no change. On the seeded world + exactly **one** house qualified, and it was already at IDOC, so the walk had to put it back down + first. diff --git a/modules/uo/API.md b/modules/uo/API.md index af69c1a..b67d98c 100644 --- a/modules/uo/API.md +++ b/modules/uo/API.md @@ -153,3 +153,231 @@ changes something — with deliberate exceptions, which are the leaks it was wri `/public/shard/guilds`, `/public/shard/governors` and `/public/shard/feed` previously returned the raw stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened `ownerAcct`. All are now stripped for every caller below admin. + +## 5. Engagement triggers and audiences (ENGAGEMENT.md Phase 11) + +Not an HTTP surface, and it is here anyway: it is the other thing this module registers with core, and +it is the one an operator interacts with by name. `module-uo` declares **26 event triggers** and +**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`, and ships +**34 message bodies and 26 rules** through `api.registerEngagementSeeds` +([`MODULE_API.md`](../../website/MODULE_API.md) §2.4). Core never learns a word of the vocabulary — +it holds an id, a label, a variable list, a ceiling and, for an audience, a `resolve` it may call. + +**What a trigger is, and what it is not.** It is a *payload contract*: what a rule may fire on, what a +template may interpolate, and — the part that is a security boundary — the widest audience an operator +may ever give it. Declaring one sends nobody anything. An operator has to write a rule, and every rule +core or this module seeds ships `enabled = 0`. + +The declarations live in +[`server/config/shardTriggers.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/shardTriggers.js); +the wire-kind mapping that fires them is `server/utils/shardEngagement.js`, hung off `shardIngest` +beside the SSE broadcast and the push tickle. + +### 5.1 The catalogue, by ceiling + +The **ceiling** is the widest audience a rule may ever be given for that trigger. It is checked when a +rule is saved *and* again at send time, and it is ordered by **containment, not size** — a `staff` +ceiling does not permit `owner`, because fewer people is not less exposure. + +| Ceiling | Triggers | Why that ceiling | +| --- | --- | --- | +| `owner` | `uo.house.idoc_warning`, `uo.house.refreshed`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All twelve resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify | +| `members` | `uo.guild.left`, `uo.guild.disbanded` | The guild's roster, resolved to website users through `shard_account_links` and carried on the emit as `recipientUserIds` — "the members of *this* guild" is a different answer every firing, which a saved segment cannot express | +| `authenticated` | `uo.governor.elected`, `uo.election.opened`, `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down`, `uo.points.rank_changed` | Public shard news. Each defaults to `subscribers`; the ceiling permits an operator to widen to everyone signed in, which for "the shard is back up" is a defensible thing to want | +| `staff` | `uo.page.new`, `uo.cheat.detected` | `uo.cheat.detected` is the declaration the lattice was written for: under a flat "fewer people is narrower" ordering, a `staff` ceiling would also permit `owner`, and the rule an operator could then save mails the cheat report to the player who was detected | +| `admin` | `uo.audit.staff_action`, `uo.economy.milestone`, `uo.world.saved` | `staff` means admin, editor **and** moderator, so a digest of what staff did in game must not ceiling there. `admin` was added to the lattice for these three (MODULE_API 1.8.0) | + +A `staff`- or `admin`-ceilinged id **does not appear by name in a player's preferences catalogue** — +core filters the catalog on the ceiling, so a control that could do nothing is never offered and the +event's existence is not disclosed. + +### 5.2 Four rows that are deliberately absent + +[`ENGAGEMENT.md`](../../website/ENGAGEMENT.md) §8.6 catalogues the candidate events and Phase 11 commits +to shipping every one of them, so a row that does *not* ship needs a recorded reason. There are four: + +| Not shipped | Reason | +| --- | --- | +| `uo.market.item_listed` | A saved **search**, not a trigger — its audience is "users whose stored query matches this listing", and no per-user query store exists. Its own workstream | +| `uo.guild.joined` | Core's `team.member.joined` already fires for it: a UO guild **is** a Team and this module is the deployment's Team provider, so the roster reconcile emits on every join. A second trigger is two mails for one event | +| `uo.link.requested` | No addressable recipient **by construction** — the account is not yet linked, which is the point of the event — and a ~5-minute `ttlSec` no channel can beat | +| `uo.points.rank_changed`'s personal half | `points.board`'s `top[]` names a mobile **serial** and `shard_account_links` is keyed by **account**. The board-change feed ships; "you were pushed out" would reach some players and silently not others | + +### 5.3 Two triggers that need a running patch tier or a v5 overlay + +- **`uo.vendor.sale` requires the opt-in ServUO patch tier.** `vendor.sale` is emitted by a + `PlayerVendorSale` EventSink that lives in `servuo-plugins/patches/`, not in `overlay/`. A shard that + declined the tier emits the kind never, so a rule on it is **silently dormant rather than broken** — + which is why the declaration's own operator-facing description says so. +- **`uo.house.idoc_warning`'s schedule and `uo.vendor.expiring` need protocol 5.** Both read fields the + v5 overlay added ([`link/v5.md`](../../link/v5.md)). The warning still fires on a v4 shard, simply + without `nextStage` / `estimatedCollapse`; `uo.vendor.expiring` needs the `fees` block and does not + fire at all without it. **An absent `estimatedCollapse` means "not knowable", never "not yet read"** + — under dynamic decay ServUO draws each stage's duration at random, so the mapper passes the absence + through rather than computing a guess the shard refused to publish. + +### 5.4 Three things a rule cannot express, done in the mapper instead + +Most rows are a field mapping. Three are not, and each is in the mapper rather than in a rule condition +because `conditions.js` compares a declared variable against a **literal** — no arithmetic, no relative +time, no previous value. + +- **Transitions.** `champ.update` and `city.update` are full-state upserts re-emitted on any change, so + without a per-process tracker a sidecar reconnect reads as twenty champion spawns starting at once. A + **first** sighting is never a transition. +- **Thresholds.** `uo.vendor.expiring` fires on the crossing into a 48-hour window and not on every + sweep frame (a shop is re-emitted whenever anyone reprices an item); a deposit that leaves the window + re-arms it. `uo.economy.milestone` crosses a gold or account line, in either direction, never on + first sight. Both declare an int (`hoursRemaining`, `value`) so an operator can still narrow with + "is at most". +- **`uo.server.up` / `down` is the cooldown table's stress test.** `server.hello` arrives on every + *sidecar* reconnect, not only a shard restart, so the tracker suppresses a hello while the shard is + already believed up — and the seeded rule carries a hard cooldown for a shard genuinely flapping. + +### 5.5 The three audiences + +Named sets of **people** an operator points a rule at or composes into a saved segment with and/or/not. +A different mechanism from the `members` audience the guild triggers use: a registered audience answers +the same question every time it is asked, which is what makes it storable. + +| Audience | Params | Ceiling | Resolves to | +| --- | --- | --- | --- | +| `uo.guild.members` | `guildId` (int) | `members` | Everyone with a linked game account on that guild's roster | +| `uo.governors` | — | `members` | Everyone with a linked account holding a city governorship | +| `uo.linked.accounts` | — | `members` | Every website user with at least one linked game account — and, composed under `not`, the audience for the message asking the rest to link one | + +Each resolver returns **user ids and nothing else** — never an address, a channel or a template — and +each fails to the **empty set** rather than throwing, because an audience that cannot resolve is a rule +that reaches nobody rather than one that breaks the engine. + +### 5.6 Where the ordering matters + +The engagement fan-out runs **before** `shardIngest` applies the frame's state change, and that is +load-bearing. Three mappings read a row the state write is about to delete or replace: +`account.unlinked` drops the `shard_account_links` row that names the one person who needs to be told; +`house.remove` drops the house whose stored `ownerAcct` is the only place a collapsed house's owner +appears (the frame carries a serial alone); and `guild.leave` / `guild.remove` need the roster and +board mirrors to name who left and which guild it was. Resolving afterwards finds nobody, every time. + +### 5.7 The shipped bodies (Phase 11b) + +Declaring a trigger says what an event IS. It says nothing about what the message reads like, and +until Phase 11b there was no way for a module to say: `templateSeeds.js` and `coreRules.js` are core +files with core arrays in them. `api.registerEngagementSeeds({ templates, ruleGroups })` +([`MODULE_API.md`](../../website/MODULE_API.md) §1.1, **1.9.0**) is the mechanism; this module is its +first caller, with **34 bodies and 26 rules** in +[`server/config/engagementSeeds.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/engagementSeeds.js). + +**Sixteen families read from inside Britannia, with a per-family sender.** The org lead's decision +(ENGAGEMENT.md Phase 11b, decision 8) was a sender per family rather than one voice across all of +them: a shard where Lord Blackthorn writes to you personally about a champion spawn is a shard where +the letter about your governorship means nothing. + +| Sender | Families | +| --- | --- | +| **Lord Blackthorn's court** | the governorship, the elections — the crown's business and nothing else | +| **the Office of Deeds** | houses | +| **the Merchants' Guild** | vendors | +| **a guild herald** | guild departures and dissolutions | +| **the town crier** | champion spawns | +| **a guildmaster** | skills, quests | +| **the Chronicler of the Dead** | deaths and murders | +| **the keeper of the rolls** | leaderboards | + +Each ships an `email` body and an `inapp` body **in the same voice** — one rule fires on both at once, +and a player who reads the inbox item and then the mail must not meet two different narrators. The +**digest** stays core's generic `notify.digest`: a day of events rolled into a list is not a letter +from anybody. + +**Nine stay plain, and the line is where fiction costs something real** (decision 9). Both +account-security triggers, `uo.server.up`/`down`, and the five staff- and admin-ceilinged ones point +at core's `notify.event` / `inapp.event` and author nothing — which is also §4.6.1 property 1 being +exercised at scale. A failed-login notice written as *"a stranger sought entry to thy account"* is +indistinguishable in register from the phishing mail it warns about, and a moderator reading +`uo.cheat.detected` at two in the morning wants a name, a rule and a timestamp rather than a scroll. + +**An operator whose shard is not Blackthorn's Britannia edits these rows.** The template editor is +where, and `customized = 1` then protects the edit from every later seed — the bodies are defaults, +not fixtures. + +Two mechanical notes that will bite whoever adds the twenty-sixth trigger: + +- **All 26 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended + to it later reaches **fresh installs only** — never a deployment already stamped. A rule that must + reach existing deployments takes a new group key. +- **A trigger id and a template key have different grammars.** `uo.champ.boss_up` is a legal trigger + id and an illegal template key (core's key pattern admits `.` and `-`, not `_`), so its body is + keyed `uo.champ.boss-up`. Registration refuses the mistake at boot. + +### 5.7a `uo.house.refreshed` — the trigger that exists to cancel one + +Added by the live walk (ENGAGEMENT.md Phase 11b, decision 11), and it is the only trigger in this +module whose primary job is not to say something. + +`uo.house.idoc_warning`'s seeded rule carries `delay_seconds: 900` so that a player who repairs the +house inside the quarter-hour is never told it is in peril. That is only true if something CANCELS +the pending row, and until this trigger existed nothing could: `cancel_on` named `uo.house.collapsed` +— the outcome where the warning is pointless — and the mapper returned early on every transition that +was not a late decay stage, so a refresh reached the engine as silence. The wire had carried the +transition all along. + +``` +house.decay Greatly -> Ageless (the owner logged back in) + -> uo.house.refreshed (owner-audienced, subject = the house serial) + -> cancels every scheduled engagement_outbox row for + (the warning's rule, that house, that owner) + -> and, if the operator enabled its own rule, sends the Office of Deeds' + one non-warning letter +``` + +Three things about it are load-bearing: + +- **Its `subjectKey` is `houseSerial`, the same as the warning's.** `outboxDb.cancel` matches on + `(rule, subject_key)`, so a refresh carrying any other subject would cancel nothing at all. +- **It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case.** A condemned house + cannot be refreshed — `BaseHouse.RefreshDecay()` refuses `DecayType.Condemned` outright — so the + rescue is the owner logging in. Their newest house then becomes `AutoRefresh` and reads `Ageless`; + an older one becomes `ManualRefresh` and reads `LikeNew`. Reading only the second misses most + rescues. +- **The cancellation does not depend on its own rule.** `cancel_on` is read off the WARNING's rule, + so an operator who wants the cancellation without the reassurance letter simply leaves the new rule + disabled — which, every seeded rule shipping disabled, is what a fresh install already does. + +### 5.7b Every link a body offers comes from `config/clientPaths.js` + +A notification's call-to-action is a path into this module's own SPA routes, and there is exactly one +place that knows them. The live walk found every one of them wrong, in two independent ways: + +- the declared `example`s read `/shard/…`, taken from `module.json`'s `mounts` — but + `registry.registerRoutes` prefixes a module's client routes with its **ID** and nothing else, so + the real paths are `/uo/houses`, `/uo/market`, `/player/uo/characters`. Every example was a 404, + and an example is what the template editor previews and test-sends with; +- and no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href + and dropped out of the text part entirely. + +`clientPaths.js` is now the single source for both the declarations and the bodies. `client/src/entry.jsx`'s +own `registerNav` is the cross-check: the hrefs it hands the sidebar are these, and if the two ever +disagree the sidebar is right. + +### 5.8 The presentational fragments, and why they exist + +A template has **no conditionals**, by design, and an unset optional interpolates to the **empty +string**. That is right for a structural body and wrong for a sentence: *"Be it known that , +recorded to thy name, is this day found ."* So the ternary stays in `shardEngagement.js` and its +result arrives as a declared optional — Phase 5a's `forWhom` precedent. Two shapes, and the `example` +on each declaration shows which it is: + +- a **LABEL** always has a value and carries a sentence's spine. `houseLabel` is the name and region, + falling back to the seal number, because a warning has to name something the owner can act on. +- a **TRAILING FRAGMENT** may be empty and leads with **its own space**, so `{{slainBy}}.` closes as + *"has fallen."* either way. + +They are declared `required: false` deliberately: a required variable missing **refuses the emit**, +and a dropped notification is worse than a cosmetic hole. Nothing at runtime therefore notices a +mapper that forgot one, so `server/test/engagementSeeds.test.js` asserts every label is supplied on +every path that emits its trigger. + +A whole **detail line** works the same way one level up (`ledgerLine`, `whereLine`): four optional +numbers assembled into a sentence by the mapper, and absent entirely when the frame carried none of +them — the same argument `place()` makes for coordinates. A pre-v5 vendor frame otherwise renders +*"On hand: gold. Charged each period: gold."* diff --git a/website/API_V2_PLAN.md b/website/API_V2_PLAN.md index a95f987..b0a5dca 100644 --- a/website/API_V2_PLAN.md +++ b/website/API_V2_PLAN.md @@ -14,6 +14,14 @@ Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md` > 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per > business capability, **in place, with every URL unchanged**. This is the actual driver. +> **Later change, recorded here so the tables below are not read as current (2026-08-29).** The router +> inventories in this file are a **record of the split as it landed**, and are deliberately left as +> written. Since then, `admin/account.router.js` (6 routes) and `player/account.router.js` (8 routes) +> were **deleted**: `/auth/me/account/*` was already a strict superset of both, and is now the single +> self-service surface. `account.controller.js` moved to `router/v1/auth/`. See +> [ENGAGEMENT.md](./ENGAGEMENT.md) Phase 1a and [BACKEND_DESIGN.md](./BACKEND_DESIGN.md) §4. The +> authoritative URL list is, as this file argues throughout, the generated manifest — never a table. + --- ## Why the auth merge is out diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 34c0ee1..0c0baf9 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1,1264 +1,1842 @@ -# Runic Gateway Website — Backend Design - -> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding. -> This document is the contract the later phases build against. - -**This is core's contract, and core is game-agnostic.** Nothing here is specific to any one game or -instance: the site's name, colours, logo and public contact address are data -(`BRAND_*` / the `settings` table), and everything about a *particular* game arrives from an -installed module — see [MODULE_SYSTEM.md](MODULE_SYSTEM.md) and, for the worked example, -[../modules/uo/](../modules/uo/README.md). **UOMysticmoon** is the first instance, and appears -below only as an example value. - ---- - -## 1. Stack & top-level decisions - -| Concern | Decision | Rationale | -|---|---|---| -| Runtime | Node.js + Express | serverlinkr pattern | -| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) | -| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel | -| Frontend | React + Vite, same repo, served by Express in prod | spec | -| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr | -| Deploy | Docker Compose (app + db) behind Pangolin | spec | - -**Adapting serverlinkr → this project** -- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them. -- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth". -- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**. - ---- - -## 2. Folder structure - -Skeleton from the spec, with a small number of justified additions marked **(+)**. - -> **Complete.** The monolithic route files (`admin.routes.js` especially, originally 1552 lines / -> 110 routes) have been split into one router file per business capability — **in place, with every -> URL unchanged**. See [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2. -> -> `users`, `account`, `invites`, `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`, -> `activity` (PR 2, 18 routes), `posts`, `uploads`, `wiki`, `pages` (PR 3, 31 routes) and `shard`, -> `uo-link`, `email`, `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes) each live -> in their own router under `admin/`, behind `admin/index.js`. PR 5 did the same for `public/` (24), -> `player/` (20) and the residual `auth/` (10). **`admin.routes.js`, `public.routes.js`, -> `player.routes.js` and `auth.routes.js` are all deleted**; each group is now a directory whose -> `index.js` owns the group gate and the mount table and declares no routes of its own. -> -> "Every URL unchanged" is enforced mechanically, not by review: `server/scripts/routeManifest.js` -> (`npm run routes:manifest`) walks the live Express stack and writes the sorted -> `{ method, path }` freeze to `server/routes.manifest.json`, mirrored here as -> [api-route-inventory.json](./api-route-inventory.json). PR checks regenerate it and fail on any -> diff, so a split PR that moves a URL cannot merge silently. See § 4.0. - -``` -server/ - .env.example - package.json - db/ - schema.sql (+) DDL, also auto-run by the MariaDB container - seed.js (+) seed wiki pages, default settings, first admin - src/ - server.js bootstrap: core schema, seed, resolve MODULES, require app, module - schema fragments, module onBoot, then listen on 0.0.0.0 - app.js express app + middleware wiring - router/ - api.router.js mounts /v1 - v1/ - v1.router.js mounts /auth /public /admin /player - auth/ index.js mounts the routers below; no group gate — /auth - is where an anonymous caller becomes - authenticated, so the authenticated parts gate - themselves. Mount order is load-bearing (see - session.router.js) - login.router.js (2) /auth/login + /login/totp — shared - loginGuards stack - register.router.js (1) /auth/register — honours the - player_registration setting - invite.router.js (2) /auth/invite/:token[/accept] — the - token is its own authority, so it - bypasses player_registration - password.router.js (3) /auth/password/forgot + reset/:token - session.router.js (2) POST /logout and GET /me — the two - singletons owning no path segment, so - mounted at the group root, LAST: the - /me sub-routers below also match the - bare /me and supply its noindex header - me.routes.js (23) /auth/me/account*, sessions, trusted - devices — router-level requireAuth - notifications.routes.js (3) /auth/me/devices*, notifications/* - mobile.routes.js + /auth/mobile/* — native bearer login - mobileSso.routes.js (5) - sso.routes.js (4) mounted PATHLESS: owns two prefixes, - /auth/providers and /auth/sso/* - loginGuards.js shared backoff/slow/limiter stack for - every credential-guessing surface - (not a router) - auth.controller.js + invite/passwordReset/sso/mobile controllers - public/ index.js mounts the routers below; **no group gate** — - this surface is anonymous by design (SPA - logged-out, Discord bot, Android ShardStream) - posts.router.js (2) /public/posts/:category[/:idOrSlug] - wiki.router.js (4) /public/wiki — /categories and /tags - MUST precede /:slug - pages.router.js (2) /public/pages — the draft-preview - route precedes /:slug and is - deliberately not site-mode gated - modules.router.js (1) /public/modules — the installed-module - list a client feature-detects against. - A real prefix and not a fifth singleton - below, so the module loader's - collision probe (which skips - root-mounted layers) sees it - site.router.js (4) /settings /status /version /contact — - the group-root singletons; declares no - router-level middleware - public.controller.js - (/public/shard and /public/atlas are module-uo's — see - ../modules/uo/API.md) - player/ index.js owns the shared `noindex, requireAuth` gate - (authenticated, ANY role — staff are a superset - of players) and the mount table - account.router.js (8) /player/account — credentials, TOTP, - linked identities; handlers shared - with /admin/account and /auth/me - appeals.router.js (4) /player/appeals - appeals.controller.js - (/player/shard is module-uo's) - settings/ index.js owns the shared `noindex, requireAuth` gate - (authenticated, ANY role) and the mount table. - A fifth group, for site-wide settings that - need a login but no particular role — /public - is anonymous, /admin/settings is adminOnly - while AdminLayout renders for editors and - moderators, and /player is self-scoped data - nav.router.js (1) /settings/nav — the nav_admin and - nav_player overrides, read by the - layouts that render them - theme.router.js (1) /settings/theme/options — the closed - sets the admin appearance form is - built from. Static; no DB read - nav.controller.js + theme.controller.js - admin/ index.js mounts the capability routers below at their - own prefixes; owns the shared - `noindex, isLoggedIn, staffOnly` gate and - declares no routes itself - account.router.js (6) /admin/account — self-service, no adminOnly - users.router.js (9) /admin/users — adminOnly. The six - /users/:id/shard/* routes are a - MODULE's, reached through the - admin.users.detail extension slot - invites.router.js (3) /admin/invites — adminOnly - authProviders.router.js (4) /admin/auth — adminOnly - moderation.router.js (15) /admin/moderation — modAccess - (admin+moderator) at router level - botActivity.router.js (2) /admin/bot-activity — adminOnly - activity.router.js (1) /admin/activity — staff-wide - audit log, no extra gate - posts.router.js (9) /admin/posts — editor tier, no - gate beyond staffOnly - uploads.router.js (1) /admin/uploads — rich-text editor - image upload - wiki.router.js (14) /admin/wiki — pages, revisions, - categories, tags - pages.router.js (7) /admin/pages — CMS page builder - imageUpload.js shared multer config for the two - upload routes above (not a router) - modules.router.js (8) /admin/modules — adminOnly, the - module delivery surface: install - from a manifest URL, enable, - disable, uninstall, purge, restart - and the source allowlist - email.router.js (6) /admin/email — Gmail OAuth2 - delivery — adminOnly - discordBot.router.js (2) /admin/discord-bot — adminOnly - settings.router.js (4) /admin/settings — adminOnly. The - DELETE /:key is "reset to default" - and carries its own key allowlist - (theming/nav keys + the hero draft) - so it can never drop site_mode or - a module's own seeded row; POST - /brand-asset/:slot uploads a - logo/hero/favicon and writes the - brand_assets row in the same call - dashboard.router.js (2) GET /dashboard (staff-wide) and - PUT /site-mode (adminOnly) — the - two singletons owning no path - segment, so mounted at the group - root; declares no router-level - middleware, which is what makes a - root mount safe - admin.controller.js + the per-capability controllers - (already domain-split; the split PRs re-wire - routes, not logic) - (/admin/shard and /admin/uo-link are module-uo's) - model/ - users/ users.model.js + users.db.js - posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots) - wiki/ wiki.model.js + wiki.db.js - settings/ settings.model.js + settings.db.js - activity/ activity.model.js + activity.db.js (+) admin activity log - middleware/ (+) - siteMode.js LIVE/MAINTENANCE gate for public content - noindex.js X-Robots-Tag: noindex,nofollow on admin - rateLimit.js login limiter - validate.js express-validator error handler - utils/ - auth.js JWT sign/verify, isLoggedIn middleware - db.js MariaDB pool + ensureSchema() - mailer.js (+) nodemailer; mailto fallback if SMTP unset -client/ built in Phase 2/3 (React + Vite) -Dockerfile -docker-compose.yml -.env.example -.gitignore -``` - -**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode -gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four -listed models/two utils. They're isolated in `middleware/` + one `activity` model + -`utils/mailer.js`, and the spec explicitly says the layout is "expandable." - ---- - -## 3. Database schema (MariaDB) - -`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as -`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`. - -### users -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| username | VARCHAR(32) UNIQUE NOT NULL | | -| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API | -| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow | -| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | -| last_login_at | DATETIME NULL | shown in user management | - -### posts — one table, four categories -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | | -| title | VARCHAR(200) NOT NULL | | -| slug | VARCHAR(220) NULL | optional clean URL | -| excerpt | VARCHAR(400) NULL | list teaser | -| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter | -| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere | -| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle | -| author_id | INT NULL FK→users(id) | ON DELETE SET NULL | -| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | -| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | | -| published_at | DATETIME NULL | set when first published; list order | - -Index: `(category, published, published_at DESC)`. - -### wiki_pages -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` | -| title | VARCHAR(200) NOT NULL | | -| body | MEDIUMTEXT NULL | markdown/HTML | -| updated_by | INT NULL FK→users(id) | | -| created_at / updated_at | DATETIME | | - -Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`. - -### settings — key/value, expandable -| col | type | notes | -|---|---|---| -| `key` | VARCHAR(64) PK | | -| value | TEXT NULL | | -| updated_by | INT NULL FK→users(id) | | -| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | | - -Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`, -`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`, -`contact_email` (seeded from `BRAND_CONTACT_EMAIL`; e.g. `UOMysticmoon@gmail.com` on the first -instance), `site_title`, `player_registration` -(default `disabled`), `mobile_app_links_enabled`, `module_source_hosts`. - -`module_source_hosts` is the allowlist of hostnames a module may be installed from -(MODULE_SYSTEM.md §2.7.2 decision 6), edited in Admin → Modules and audited as -`module.sources`. It is **bootstrapped** from `MODULE_SOURCE_HOSTS` and not owned by -it: `seedDefault` is an `INSERT IGNORE`, so the environment supplies a default on a -fresh install and changing the variable later cannot reach back in and overwrite what -an operator chose. Installs are `https`-only, every redirect hop is re-checked against -this list, and an empty value forbids every install rather than allowing every host. - -The **`MODULES`** environment variable (MODULE_SYSTEM.md §2.7.2 decision 4) installs through the same -allowlist and the same verification, without a request: each `@=` entry is -resolved onto the modules volume during boot, between `seedDefaults()` and the `require` of `app.js` -that scans it. It is not a settings row and is not editable from the panel — a deployment declares -what it runs, the panel shows that it did, and neither owns the other: the variable decides what is -on the volume and `installed_modules.state` decides whether a module answers. - -**Keys a MODULE seeds into this table.** `settings` is core's, but a module's -schema fragment may `INSERT IGNORE` its own rows into it, and module-uo seeds two: -`game_account_signup` (default `disabled`) and the one-shot migration marker -`uo_link_protocol_3_migrated`. Core seeded both until Phase 3 slice 4, which is -worth knowing for one reason beyond tidiness — a fragment runs **after** core's -schema is replayed in full, so a marker in core guarding a statement in a fragment -fires before the statement reads it. That exact ordering silently disabled the -protocol-3 migration between slices 1 and 4; see MODULE_SYSTEM.md §2.7.1. - -**Deliberately unseeded keys** — the theming & navigation overrides -(`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`). All -five are JSON strings, and **the absence of the row is the "use the default" -state**: colors/fonts/radii fall back to `theme.css`, assets to `BRAND_*`, navs -to the hardcoded `NAV` arrays. No migration writes defaults into them, because a -stored copy of a default would stop tracking the default. Resetting one is -therefore a `DELETE`, not a write — see `DELETABLE_KEYS` in `settings.model.js` -and [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §2. - -Values are `TEXT`, so a JSON-valued key arrives as a **string** and every -consumer parses it. Server side that is `utils/settingsJson.js` -(`parseJsonSetting`), client side `client/src/lib/settingsJson.js` and -`parseLayout`; both treat a malformed or wrong-shaped value as **absent** rather -than as an error, so a hand-edited row degrades to the default instead of -rendering something broken. - -**The three `nav_*` rows are presentation, never authorization.** An entry is -keyed by an item's existing `to` and may carry only `label`, `order`, `hidden` -and — admin nav only — `group`; `utils/navOverrides.js` rejects anything else on -write, naming the key. It deliberately does **not** check that a `to` exists: the -base `NAV` arrays are client constants, and duplicating them server-side would -create a second source of truth for navigation that drifts the first time a route -is added. `client/src/lib/navOverrides.js` drops an unknown `to` at merge time -instead, which is also what makes deleting a route in code safe. The merge runs -*before* the role and feature filters in `SiteHeader.jsx` / `AdminLayout.jsx` — -a `feature` on a nav row is resolved by the module that **registered** the row -(`client/src/modules/featureGate.js`), so no flag string carries a parsed prefix -and core learns nothing about a game — and those filters remain the boundary: a stored -`hidden: false` on a gated item shows nobody anything. `hidden: false` is -accepted (the editor sends it mid-edit) but never stored, so hiding stays -subtractive. `hidden` on `/admin/navigation` is dropped for `nav_admin`, because -that screen is the only UI that can un-hide anything. - -**`nav_public` may also carry dropdown sections and admin-authored links**, as -`{ items, sections, links }` — a bare map still reads as `items`, and a nav with -no sections still stores one. A **section** has a label and a position and no -route at all: it only opens, so it adds no reachable surface. A **link** is the -one place a path may be named that the code does not declare, and is therefore -the one place the path rule applies: same-origin only, no scheme and no -protocol-relative `//host`. A link carries no gate of its own and needs none — -the page behind it enforces its own access, so an added link advertises a route -and never grants one. Coded entries stay in `items`, keyed by a route the base -array must declare, which is what keeps "an override cannot introduce a route" -structurally true. Sections and links are dropped for `nav_admin` / `nav_player`, -whose layouts cannot render them. - -**`theme_visual` is resolved server-side, not shipped raw to the browser.** -`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into -the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job -is to write them onto `` and take back what it wrote last time -(`client/src/lib/themeVars.js`). One authority for the merge means the effective -accent in `brand.accent` — the cross-repo contract the Android app and the -Discord bot theme themselves from — always agrees with what the website paints. -Values reaching a CSS variable are checked against closed sets on both paths: -strictly on write (400, naming the field) and forgivingly on read (drop the bad -field, keep its neighbours). - -### activity_log — append-only -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| user_id | INT NULL FK→users(id) | | -| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` | -| detail | TEXT NULL | JSON string of what changed | -| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) | -| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | - -### password_resets — self-service reset links -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 hex of the opaque token; **plaintext never stored** | -| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the account this reset targets | -| status | ENUM('pending','used') DEFAULT 'pending' | single-use (atomic `markUsed`) | -| requested_ip | VARCHAR(64) NULL | who asked (audit only) | -| expires_at | DATETIME NOT NULL | ~1h TTL, enforced in the model on top of this | -| created_at / used_at | DATETIME | | - -Same "store only the hash of an opaque token" pattern as `user_invites` / `mobile_refresh_tokens`. -A DB read never yields a usable reset link. See §4 `/auth/password/*`. - -### push_devices — opt-in push endpoints (M7) -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | owner | -| transport | ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' | UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor | -| endpoint | VARCHAR(512) NOT NULL | the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but **not a secret** — stored in the clear (unlike refresh tokens), because pushes are content-free tickles | -| platform | VARCHAR(40) NULL | free-form label, e.g. `android` | -| created_at / last_seen_at | DATETIME | | - -`UNIQUE(user_id, endpoint)` — re-registering the same endpoint is an idempotent upsert. - -### notification_subscriptions — which streams a user opted into (M7) -| col | type | notes | -|---|---|---| -| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | | -| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`modules/registries.js` — core's plus every installed module's), validated on write | -| created_at | DATETIME | | - -`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT -replaces the whole set. Nothing is pushed unless the user subscribed. - -### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9) - -Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They -carry the **app ↔ website** PKCE + CSRF state (a *second* PKCE layer, distinct from the website ↔ IdP -PKCE the `sso_tx` cookie already carries) and the one-time authorization code the app exchanges for -bearer tokens. Neither holds a secret in the clear — the PKCE `code_challenge` is a hash by -construction, and the authorization code is stored as a **sha256 hash only** (same pattern as -`user_invites` / `password_resets` / `mobile_refresh_tokens`). - -`mobile_auth_sessions` — one row per `/auth/mobile/sso/start`: - -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| session_id | CHAR(36) UNIQUE | opaque uuid; carried inside the signed `sso_tx` (mode `mobile`) so the callback can find this row | -| provider | VARCHAR(40) NOT NULL | provider id validated enabled at `/start` | -| code_challenge | VARCHAR(255) NOT NULL | app-supplied PKCE S256 challenge (base64url); verified at `/exchange` | -| redirect_uri | VARCHAR(255) NOT NULL | the requested app callback — **exact-match** against the allowlist (never prefix) | -| state | VARCHAR(255) NOT NULL | app-generated opaque CSRF value, echoed on the callback for the app to verify | -| status | ENUM('pending','completed','consumed') DEFAULT 'pending' | `pending`→`completed` when the code is minted; `consumed` after a successful exchange | -| user_id | INT NULL FK→users(id) ON DELETE CASCADE | set once SSO resolves the account | -| trust_device | TINYINT(1) NOT NULL DEFAULT 0 | user ticked "trust this device" on the Custom Tab TOTP form. A **boolean only** — it tells `/exchange` to mint the app's own trust token; the token never rests here (only its sha256 reaches `trusted_devices`) | -| expires_at | DATETIME NOT NULL | short (~10 min — one redirect round-trip incl. TOTP) | -| created_at / used_at | DATETIME | `used_at` stamped at exchange | - -`mobile_auth_codes` — one row per completed SSO callback (the code the app redeems): - -| col | type | notes | -|---|---|---| -| id | INT PK AUTO_INCREMENT | | -| code_hash | CHAR(64) UNIQUE | sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB | -| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | the authenticated account | -| session_id | CHAR(36) NOT NULL | the owning `mobile_auth_sessions.session_id` (ties the code to its PKCE challenge) | -| expires_at | DATETIME NOT NULL | very short (~5 min) | -| used_at | DATETIME NULL | set on first successful exchange — **single use** (a reused code fails) | -| created_at | DATETIME | | - -Both self-prune (indexed `expires_at`): a best-effort sweep runs at boot beside the existing -`revoked_sessions` prune, and each bridge write opportunistically deletes expired rows — so no cron -infra is added (same approach as `revoked_sessions`). - -**`mobile_refresh_tokens` additions (M9).** Two nullable columns are added to support the device -list/revoke surface: `device_name VARCHAR(100) NULL` (a friendly label) and `last_used_at DATETIME -NULL` (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model -is otherwise unchanged. - -### trusted_devices — MFA "Trust this device" - -Lets a browser/app **skip the TOTP step** at login (never the password) for 30 days. Pattern-identical -to `mobile_refresh_tokens`: the opaque trust token lives client-side (the `rg_trust` httpOnly cookie on -web, `X-Trust-Token` / EncryptedSharedPreferences on native) and only its **sha256** hash is stored -(`token_hash CHAR(64) UNIQUE`) — sha256, not bcrypt, because a 256-bit random token is looked up **by -its hash** via the unique index (a per-row salt would break that). Columns mirror the mobile table -(`platform`, `device_name`, `device_hash`, `user_agent`, `created_at`, `last_used_at`, `expires_at`, -`revoked_at`). Capped at 10 rows/user **in application code — no silent pruning** (an over-cap trust is -refused so the client can prompt the user to revoke one first). Consulted only at the login/password -step, never at token refresh, and revoked wholesale on untrust / password change / password reset / -TOTP disable. See `docs/website/TRUSTED_DEVICES_MFA.md`. - -### recovery_codes — single-use MFA backup codes - -Generated at TOTP enrollment (10 at a time, shown to the user **once**) so a user who loses their -authenticator can complete login without an admin reset. `code_hash VARCHAR(72)` is a **bcrypt** hash -(not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest -analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10 -unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use -marker. Cleared wholesale on TOTP disable / password change / password reset. - -### The 27 shard tables — module-owned (module system) - -`shard_*` and `uo_link_config` are **not core's**. They are created and dropped by `module-uo`'s own -schema fragment, and a core running without that module has none of them. Their shapes and the -reasoning behind them live with the module: -[`../modules/uo/SCHEMA.md`](../modules/uo/SCHEMA.md). - -The prefixes are grandfathered ([`MODULE_API.md`](MODULE_API.md) §6.5) — a new module prefixes its -tables with its own id. - -### installed_modules — what is installed, and what happened to it (module system) - -One row per installed module, keyed by the `id` from its `module.json` — the same id that names its -directory on the modules volume and its URL segment. - -| Column | Shape | -|---|---| -| `id` | VARCHAR(32) PK — the module id | -| `name`, `version` | the manifest's label and semver, for the admin Modules screen | -| `state` | ENUM `installed` / `enabled` / `disabled` / `started` / `startup_failed` | -| `failure_stage`, `failure_reason` | the stage a failure happened at (`manifest`, `core_api`, `mounts`, `extensions`, `schema`, `require`, `register`, `boot`) and its recorded reason | -| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand. **Written only by an admin-panel install, and `COALESCE`d on upsert** — see below | -| `installed_at`, `started_at`, `updated_at` | `started_at` is the last **successful** start | - -**This table never decides which routes exist.** The module loader scans the filesystem at require -time, before the database is reachable, so the URL surface is a property of the volume — which is what -lets `routes.manifest.json` be generated against a dead database. A disabled module stays mounted and -is guarded; the row decides whether it *answers*, not whether it is there. - -**Every boot resets each non-disabled row to `enabled`** and clears its recorded failure, then the load -writes that boot's outcome. So a `startup_failed` module is retried on the next restart (an operator -who fixes the cause needs no admin-panel visit), a running module can never display a stale reason, -and `disabled` — the one operator *decision* rather than outcome — survives untouched. A re-install or -upgrade refreshes the metadata and leaves `state` alone. - -The write happens in one place, `src/modules/lifecycle.js`, on the boot path after `ensureSchema()` -and before the listener binds: it resets the last boot's outcomes, writes a row for every module found -on the volume (with NULL provenance for a hand-placed directory), marks any row whose directory is -**gone** `startup_failed`, and then runs each surviving module's `onBoot` and records what happened. A -`disabled` row is guarded, not booted, and never has its failure re-recorded — an outcome must not -overwrite the operator's decision. Every one of those writes is individually caught: a row that will -not update is worse reporting, never a failed boot. - -**Provenance is `COALESCE`d on upsert, and that is load-bearing.** The boot write above passes NULL -for `source` and `sha256` — honestly, since a scan finds a directory and never where it came from — -so a plain `source = VALUES(source)` overwrites both columns on *every* boot, and an admin-panel -install's provenance survives only until the restart that install asks for. The statement is -`source = COALESCE(VALUES(source), source)`: a value overwrites, a NULL leaves what is there. The cost -is that hand-placing a different bundle over a row installed from a URL keeps the old provenance, -which is stale rather than blank. Found in Phase 4 by installing a module and restarting; it could not -have been found earlier, because until then no caller had ever passed a non-null value. - -Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are -[`MODULE_API.md`](MODULE_API.md) Part 4. - -### The eleven Team tables — core's, populated by a module (Teams phases 2–5) - -*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its -first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its -`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it -rather than a table of its own.* - -A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is -in them, through the team provider; core stores that answer, gates it and displays it. Every table -here is core-internal — a module must never read or write one, even though a module is what fills -them — and none carries a `_` prefix, correctly: that rule binds modules, and these are -core's. - -| Table | What it holds | -|---|---| -| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it | -| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive | -| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine | -| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection | -| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in phase 2 so the access resolver is written once; the grant flow is phase 4's | -| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings | -| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core | - -| `team_forum_threads` | forum threads (phase 4). The FULL schema lands with announcements, including the `type`, `pinned` and `locked` columns only discussion uses — phase 5 opens paths rather than migrating data | -| `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `` | -| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped | -| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist | -| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer | -| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it | -| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error | -| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer | - -**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`, -`mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere -let a *member* say "this is a problem". That was survivable while every piece of content on the site -came from staff, and stops being the moment a Team forum lets players write to each other. Four -properties are worth carrying: - -- **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a - leader-visible queue would route a complaint *about* a leader back to that leader. There is one - queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have - one place to work — and no leader-facing counterpart anywhere - ([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18). -- **A report is not a moderation action.** Filing one changes nothing about the content; it opens a - queue item. That keeps it clear of `team_forum_moderation`, which records things that actually - happened, and stops "report" becoming a way for any participant to hide anything. -- **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker` - that is `1` while open and `NULL` once closed — the same encoding as - `team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A - closed report frees the slot, so a member whose first report was dismissed may raise the same target - again if the behaviour recurs. -- **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and - declining to act is not is one where the cheapest way to make a report vanish leaves no trace. - -**`teams_forum_edit_window_minutes`** (0–1440, default 15) bounds how long an author may edit their own -post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each -post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write -re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement, -and the split exists because a time-bounded permission must not take its clock from the party it -bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the -admin screen, and the client that needs the decision already has it per post. - -**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off -means every forum route answers **404** — not 403, which would advertise a feature the operator -deliberately turned off — while threads, posts, grants and notification preferences are all untouched. -Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled -guard ([`MODULE_API.md`](MODULE_API.md) §4.5). - -**The author never writes an `` tag, and that is what makes the image policy enforceable.** The -shared sanitiser (`utils/sanitizeHtml.js`) allows `` from any host — it is tuned for the admin -editor, where the author is trusted — so the forum derives its own profile in which `img` is never -allowed in any mode. An author writes a URL; core's renderer decides at READ time whether it becomes a -picture, under `teams_forum_images` (`disabled` | `remote` | `uploads`). Three properties follow: the -policy cannot be evaded, since the only code that can emit an `` is core's; flipping it back to -`disabled` un-renders every image on every existing post with **no data migration**; and there is no -author-supplied `srcset`, `onerror` or `style` to smuggle anything through. `https:` only, on an -extension allowlist, with `referrerpolicy="no-referrer"` and `loading="lazy"` — and **the server never -fetches a user-supplied URL**, which would be an SSRF vector; the browser does. - -**`uploads` mode assumes a hostile uploader**, which the admin upload path never had to. Beyond that -path's 8 MB cap, mimetype allowlist and random filename it adds: magic-byte sniffing (a client's -`Content-Type` is a claim, not a fact), a rolling per-account byte quota, an attribution row per file, -and a nightly sweep that removes soft-deleted files past retention plus never-referenced orphans. The -sweep runs regardless of the current mode — an operator who turns uploads off still has the files. - -**Selecting `uploads` requires a recorded acknowledgement.** `PUT teams_forum_images = 'uploads'` is -rejected **400** unless the same request carries `acknowledge: `; the admin checkbox is how -the gate is presented, never the gate. The accepted TEXT VERSION is stored in -`teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log` -row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a -persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is -given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the -acknowledgement is not. - -**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth -failure, so retention ships with the feed rather than after someone notices: a nightly worker applies -an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap -(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy -guild write a million rows inside the window, and a cap keeps a dead Team's feed forever. - -`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence -trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core -deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing -a 155-member guild is one Team arriving, not 155 people joining. - -**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`, -`external_id`, `name`) taken together: a known id under a new name archives the old row -(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the -old Team keeps its activity, its grants and its forum as a read-only record and its old slug still -resolves. Whether two names are "really" the same guild is the module's judgement, expressed in -whether it reuses the external id. - -**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has -no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams` -are NULL for archived rows, so any number of them may share an `external_id`. - -**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is -`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the -generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base -column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would -delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The -semantics are identical: at most one active grant per (team, user), unlimited revoked rows. - -**Account deletion is settled per column, not inherited from the defaults.** Content and audit -survive; preferences and links do not. `team_members.user_id` and every actor column on the grant -ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record -stays readable after the account is gone. Only `team_id` cascades. - -**Two columns exist that the design of record did not contemplate**, both on `teams` and both -serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per -*module* and a single Team's roster can be left untouched while the others sync — without a per-Team -stamp that Team's page would report the module's last success as its own; and `members_empty_since`, -the per-Team twin of `pending_empty_since`. - -Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is -[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it. - ---- - -## 4. API contract - -Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also -accepts `Authorization: Bearer` for API testing). - -### 4.0 The authoritative route list - -The prose tables below are **orientation for a human reader** and can drift. Two generated artifacts -are authoritative, and they answer different questions: - -| Artifact | Source of truth for | Generated by | -|---|---|---| -| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack | -| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations | - -Both are **core's**. An installed module's routes are in neither: they are in that module's own -frozen manifest and its `swagger-fragment.json`, in its own repo, and core merges the fragment into -`/api/docs.json` at request time (§4.0.1). So on a running instance the served document describes -more than the committed one does, which is the intended arrangement rather than a drift — -`swagger-output.json` has to regenerate identically on any machine, whatever happens to be -installed on it. - -The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and -it churns whenever a description is reworded — it documents *intent*. The manifest is introspection- -derived and records *reality*, which is why it, not Swagger, is the thing PR checks freeze -(`npm run routes:manifest -- --check`). - -Both artifacts are emitted with **sorted** keys, so a diff in either is proportional to the change -rather than to how the routers happen to be traversed. `swagger.js` additionally strips trailing -slashes from generated path keys — see *Regenerating the spec* in the website README for why the -domain split makes that necessary. - -Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the -internal listener. The SPA catch-all, `/uploads`, `/brand` and `/modules` are filesystem-conditional -static mounts — not API contract, and including them would make the output depend on whether CI had -built the client, or on which modules happened to be on the volume of the machine that generated it. - -`/modules//` is the last of those and the newest: an installed module's prebuilt client chunk, -served from the directory its `client.entry` sits in and never from the module root, behind the -module's own state guard (`503` when it failed to start, `404` when disabled) and with -`Cache-Control: no-cache`, because Vite's library build emits an unhashed `entry.js`. Anything else -under `/modules` is a `404` rather than the SPA shell. The full contract is -[`MODULE_API.md`](MODULE_API.md) §3.1. - -A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route, -the middleware handler count plus the *named* middleware on its mount chain. It exists because a -router-level `router.use(noindex, isLoggedIn, staffOnly)` gate never appears in an individual route's -own stack, so a capability router extracted without re-applying the gate would otherwise publish -authenticated endpoints silently. Names are a hint only — `requireRole(...)` returns an anonymous -arrow and cannot be observed — but a *missing* `requireAuth` is unambiguous, and the server test suite -asserts every `/admin/**` and `/player/**` route still carries it. - -#### 4.0.1 `/api/docs.json` is assembled per request - -`GET /api/docs.json` and the Swagger UI at `/api/docs` do not serve `swagger-output.json` directly. -`swagger/docsSpec.js` merges the `swagger-fragment.json` of every **started** module over it first, -cached on the module loader's state version and rebuilt when a module's state moves. - -It exists because swagger-autogen is static analysis: it parses `src/app.js` as text and follows the -literal `app.use(…)` chain, which reaches neither an installed module (required by a filesystem loop, -from a volume that had nothing on it when the image was built) nor an extension slot (whose router is -created empty by `declareSlot()` and filled later). Slots are handled at generation time by -`swagger/slotSpecs.js` and are therefore *in* the committed file; modules cannot be, because core -never has their sources. - -Three rules, all from [`MODULE_API.md`](MODULE_API.md) §6.1a: - -- **`started` only.** A `registered`, `disabled` or `startup_failed` module's paths are absent — - documenting a route that answers 503 or 404 sends a client somewhere it cannot go. -- **Core wins every key collision**, in all three merged sections (`paths`, `tags`, - `components.schemas`); the collision is logged and the module's version dropped. This is what makes - the naming rule work: a module namespaces the schemas it *defines* (`UoShardStatus`) and references - core's shared ones (`Error`, `ValidationError`) by core's name, and both resolve in the merged - document. -- **A bad fragment costs that module its paths and nothing else.** Missing, unreadable or not JSON is - logged and skipped; `/api/docs.json` still answers with everything else. - -The committed spec is never mutated — it is a `require()`d JSON module, so an in-place merge would be -permanent for the life of the process *and* cumulative across rebuilds. - -### /auth (auth/index.js → the capability routers in §2) - -No group gate — `/auth` is where an anonymous caller becomes authenticated. The authenticated parts -gate themselves: `me.routes.js` and `notifications.routes.js` each apply `noindex, requireAuth` at -their own router level, and `/sso/:provider/link` carries `requireAuth` per route. - -| Method | Path | Auth | Body | Purpose | -|---|---|---|---|---| -| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at`. If the account has TOTP **and this browser is a trusted device** (a valid `rg_trust` cookie bound to the user), the TOTP step is **skipped** and a session is issued directly (logs `auth.login.trusted_device`). Otherwise a 2FA account returns `{totpRequired, challenge}`. | -| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. | -| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) | -| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state | -| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. | -| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) | -| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. | -| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) | -| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session | -| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's | -| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes | -| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities | -| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) | -| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap | -| DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) | -| GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) | -| POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off | -| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) | -| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices | -| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) | -| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) | -| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) | - -**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated -role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*` -(no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This -lets a client (the Android app) manage its own account through one surface without ever touching -`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay -for web back-compat. - -**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every -player ability plus their staff tools on top — so the whole group (`account.router.js`, -`appeals.router.js`, mounted by `player/index.js`, plus whatever a module mounts here) sits behind -the shared `noindex, requireAuth` gate **only**, never `requireRole('player')`. Every handler is self-scoped to the caller by `req.user.id`, so an admin/editor/ -moderator using it sees only their **own** linked accounts and characters (with the pre-existing -`isAdmin` bypass still letting a genuine admin read *any* character). `module-uo` inherits the rule -and relies on it: its `/player/shard/*` handlers are the identical self-scoped ones it also serves -under `/admin/shard/*`, so the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and -personal notification streams on the mobile client — the group no longer 403s a non-`player` role. - -`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in -guilds too, and gating this group on the role would 403 them off their own Teams. - -| Method | Path | Notes | -|---|---|---| -| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public | -| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied | - -**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token -whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It -also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The -reset link points at the web front end (`/account/reset/:token`); the Android app hands off here -rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped -by `seed.js` from env (see §6); further staff are created under `/admin/users` or via email invites. - -**Push notifications (M7, opt-in).** The app subscribes per stream (`/auth/me/notifications/*`) and -registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a -**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's -self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership- -checked content over the authenticated API. Two producers fan out through the one publisher: the shard -ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the -create/publish-post path for `news.post`. The catalog is assembled at boot by -`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus -each installed module's. The seven shard streams and their event→stream mapping left with -`module-uo` in Phase 3 and are registered by it; their ids are grandfathered to that module -([`MODULE_API.md`](MODULE_API.md) §6.5) because they are stored in `notification_subs` and read by -the shipped Android app. Security invariants: -- **Whether a stream is safe to publish is the registering module's decision, and it stays inside - that module.** `module-uo` applies the same public/admin split as its SSE feed — public streams are - drawn only from its own allowlist, so a sensitive kind (audit/cheat/IP/login-attempt) can never - produce a public push — and resolves personal streams (`vendor.sale`, `house.idoc`, - `account.login`) to the *owning* user's devices through its own ownership check. Core never sees a - shard event. **`utils/pushDispatch.js` publishes to a stream id someone else resolved** and knows - nothing about what produced it, which is what lets a second game's module reuse the whole pipe. -- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and - every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy - allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`). -- ntfy is treated as an **untrusted relay** — no per-user accounts, unguessable topics; an optional - `NTFY_PUBLISH_TOKEN` hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11. - -### Mobile SSO Authorization Bridge (`/auth/mobile/sso/*`, M9) - -Native "Sign in with Google/Discord" for the Android app **without shipping any OAuth secret in the -app**. The website stays the identity authority: each shard owner's provider credentials live in -`auth_providers` (encrypted at rest) and are only ever used server-side. The bridge is a **new -consumer of the existing SSO + mobile-bearer machinery**, not a parallel auth path — it reuses the -`/auth/sso/:provider/*` redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and -issues the **same** token pair as `/auth/mobile/login`. - -The TOTP gate it reuses includes the **trusted-device skip** (see -`TRUSTED_DEVICES_MFA.md` §6). Because the app opens this flow in a Custom Tab, which shares the -system browser's cookie jar, the `rg_trust` cookie set on the TOTP form is presented back on the next -app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a -tab it does not control, and without a trust token ever appearing in a start URL. - -| Method | Path | Auth | Body / Query | Purpose | -|---|---|---|---|---| -| GET | `/auth/providers` | — | — | **reused** discovery; the app renders provider buttons from this (never exposes secrets) | -| GET | `/auth/mobile/sso/start` | — (rate-limited per-IP + per-provider) | `?provider&code_challenge&state&redirect_uri` | validate provider enabled + `redirect_uri` **exact-match** allowlist; insert a `mobile_auth_sessions` row; create the existing `sso_tx` tagged `mode:'mobile'` carrying `session_id`; **302 to the IdP** (existing authorize URL) | -| GET | `/auth/sso/:provider/callback` | — (signed `sso_tx`) | `?code&state` | **existing** endpoint; a new branch when `tx.mode==='mobile'`: resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into `mobile_auth_codes`, mark the session `completed`, and **302 to `redirect_uri?code=…&state=…`** (the app's original `state`) — **no cookie is set** | -| POST | `/auth/mobile/sso/exchange` | — (rate-limited per-IP) | `{code, code_verifier}` | validate the code exists / unexpired / unused (mark used) and `sha256(code_verifier)` matches the stored challenge → issue the existing mobile access + refresh pair (`createMobileSession`) → `{accessToken, refreshToken, expiresIn, user}`. When the session carries `trust_device`, also mint a `platform:'mobile'` trusted device and add `trustToken` — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in | -| POST | `/auth/mobile/refresh` | — | `{refreshToken}` | **reused** unchanged — rotate the pair | -| POST | `/auth/mobile/logout` | bearer | `{refreshToken?, all?}` | **reused** unchanged — revoke this (or all) refresh token(s) | -| GET | `/auth/me/sessions` · DELETE `…/:id` | cookie / bearer | — | list / revoke own **mobile sessions** (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from `/auth/me/devices`, which is push endpoints) | - -**Two PKCE layers (do not conflate).** -- *Layer A (existing):* website ↔ IdP. The `code_verifier` is generated at `/start`, kept only in the - httpOnly `sso_tx` cookie, sent to the IdP token endpoint at the callback. Unchanged. -- *Layer B (new):* app ↔ website. The **app** generates `code_verifier`/`code_challenge`; the - challenge is stored in `mobile_auth_sessions` at `/start`; the verifier is presented at `/exchange`. - This is what stops an intercepted callback code from being redeemed by anyone but the real app. - -**State / CSRF.** The app-generated `state` is stored at `/start`, echoed on the callback redirect, -and **verified by the app** before it calls `/exchange` — a CSRF guard independent of both PKCE -layers (a different app instance triggering `/start` cannot complete someone else's flow). - -**Redirect-URI allowlist.** `/start` and the callback validate `redirect_uri` by **exact match** -against a configured allowlist (`MOBILE_AUTH_REDIRECT_URIS`, default the one fixed application-owned -callback `runicgateway://auth/callback`) — **never prefix match** (prefix matching on custom schemes -is a known open-redirect vector). Tokens are **never** placed in the callback URL — only the -short-lived authorization code. - -*App Links (implemented).* When the admin toggle `mobile_app_links_enabled` is **on**, `/start` also -accepts the self-origin HTTPS callback `https:///mobile/callback` — one *additive* -exact-match entry, derived from the request/`APP_BASE_URL` and never from client input; the -custom-scheme allowlist is never narrowed. The shard then auto-serves `GET -/.well-known/assetlinks.json` (fixed package `com.runicgateway.app` + `MOBILE_APP_CERT_SHA256` -fingerprints; 404 when the toggle is off or no fingerprint is configured), and -`settings.getPublic()` advertises `mobileAppLinks: `. These two things — one static file route -and one more allowlist entry — are the *entire* server surface App Links require. See -docs/android/APP_LINKS.md. - -**TOTP through the bridge.** A 2FA account keeps full parity: the callback stages the existing -pending-TOTP cookie (now also carrying the bridge `session_id`) and bounces the Custom Tab through the -web TOTP form; on a correct code the completion mints the authorization code and deep-links back to -the app — it never mints a session cookie for a mobile flow. - -**Revocation latency (documented tradeoff).** Revoking a refresh token (device revoke / logout) stops -future renewals but does **not** invalidate an already-issued access token until it expires — up to -the access-token lifetime (`MOBILE_ACCESS_TTL`, default 15 min) of continued access. This is an -accepted tradeoff given the short lifetime. If instant revocation is ever required, add an -access-token (jti) blocklist check on the `requireAuth` path — the same `revoked_sessions` mechanism -web sessions already use. - -**Authorization code.** Cryptographically random, ≥128 bits, stored **hash-only**, single-use, short -expiry (~5 min); `/exchange` is rate-limited per-IP. The bridge tables self-prune (§3). - -### /public (public/index.js → the capability routers in §2) — all GET except `/contact`, no auth - -**No group gate, deliberately.** This surface is anonymous by design: the SPA renders it logged-out, -the Discord bot reads it with no credentials, and the Android `ShardStreamClient` consumes -module-uo's `/public/shard/stream` without an `Authorization` header — a module mounting here -inherits the same "no gate" and owns whatever gate it adds. Content visibility during maintenance comes -from the per-route **siteMode** middleware (§5), never from an auth gate. - -| Method | Path | Notes | -|---|---|---| -| GET | `/settings` | whitelisted public keys, the derived `registration` flags (`gameAccountSignup` was one of these until the module extraction moved game-account policy to module-uo — it is on that module's `GET /public/shard/features` now, and the `game_account_signup` settings row is unchanged), the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL); these are **effective** values, so an admin theme (`theme_visual`) beats `BRAND_ACCENT_COLOR` and an uploaded `brand_assets` asset beats its `BRAND_*` path — an optional **`theme`** block, the resolved CSS custom properties for that admin theme (absent when the instance was never themed, which is what makes it render from the shipped stylesheet unchanged) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. | -| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard | -| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check | -| GET | `/modules` | `{ modules: [{ id, name, version, capabilities }] }` — the modules this backend is currently **serving**, in scan order (module system, `MODULE_API.md` §2.9). A module that is disabled or failed to load is **absent**, not listed with a state: its routes and nav are absent too, so the client renders a site without that capability rather than advertising one that 503s. The recorded failure stage and reason are admin-panel detail and are never published here. `capabilities` are opaque strings the module declares — feature-detect against them and treat an unknown one as absent. Like `/status` and `/version` it is **DB-free and not site-mode gated**, so a client can still feature-detect during maintenance. It is *not* how a module's client chunk loads — `htmlShell` injects a ` & `**, sent for real: + escaped in the HTML part, raw in the text part, no live tag in the delivered message. +- One accidental proof worth keeping: an early rig run had a `settings` table missing `updated_at`, and + `ambient()` degraded to the `BRAND_*` env values with a warning and sent the mail anyway. That path + is not otherwise easy to reach. + +**Still 5b's:** the editor, the admin Templates screen, the template CRUD routes, the save-time +undeclared-variable refusal (`variablesFor` is in place and is what it will ask), the sandboxed preview +and its CSP test, and the `runicgateway.com` admin docs page §6.0b assigns the pair. + +#### As built — 5b (2026-08-29) + +Built as website#TBD. **Q4 was already settled** (Phase 4a), so the only thing needing a decision before +code was the scope: §Phase 5's body names the editor and nothing else, but Q4's answer and §6.2 both +promise "Triggers, Templates and the send log" in Phase 5. **All three shipped.** Triggers is a +read-only render of two endpoints Phase 2 already serves and cost no server work; the send log is one +paged route over a table that has been filling since Phase 4a and whose index was built for it. Leaving +either out would have left the nav group half-built and **G15 — "no send log, no delivery status, no +audit" — open with the rows already on disk.** + +Five more decisions were settled by the org lead before any code, each because the tree contradicted +the plan or the plan contradicted itself. + +| | What the tree said | Decision | +| --- | --- | --- | +| **Where the preview comes from** | The client block registry mirrors the server's, but its entry shape carries a **React `component`** — page blocks are drawn in the browser. Email blocks are drawn on the SERVER; a mail body is a string this process produces | **A server preview route**, rendered into a sandboxed iframe. A React preview would be a second renderer for one artifact, agreeing with the send path on the day it was written and drifting from the first Outlook fix onward | +| **`status` was unenforced** | `draft`/`published` shipped in 5a and **nothing read it** — `getByKey` returns any row, so an operator who saved a template as a draft kept mailing it | **`renderByKey` requires `published`** and otherwise falls back to the shipped seed, the posture 5a already built for a missing or unusable row. A draft now means what the word means | +| **Test send vs. `trigger_id NOT NULL`** | §4.6.2 wants a test send "recorded in `engagement_sends` like any other message", but every transactional template has `trigger_id` NULL and there was nothing honest to put in the column | **A synthetic `core.admin.test-send` id.** No schema change, no nullable column, and the log keeps meaning one thing. It is deliberately not a registered trigger, and the screen renders it by name so nobody goes looking for it in the catalog | +| **Deleting** | `protected` blocks deletion. Nothing stopped deleting a template a rule's `template_keys` points at | **409 while a rule uses it, naming the rules** — the answer Phase 4b already gives for a segment in use, for the same reason: the alternative is a rule that silently stops producing mail | +| **Creating** | §4.6.2 names duplicate and never mentions a blank create | **Duplicate only.** Every template on a deployment descends from a shipped one that renders, which is the whole reason 5a landed first | + +##### The correction that changed the most code + +§4.6.2 introduces duplicate as "how an operator customizes a `protected` template safely: duplicate, +edit, point the rule at the copy, leave the original intact". The schema comment written in 5a says the +opposite — "Editable, NOT deletable" — and **the org lead's ruling is the schema's: a default template +is edited in place.** `customized = 1` is what stops the next seed bump from taking that edit back, and +it has been in the UPDATE's own WHERE since 5a. So `protected` now blocks deletion and *nothing else*, +and duplicate is how a NEW template comes into being rather than how an existing one is customized. + +##### Three things the plan did not know, found by building it + +**1. The variable check cannot be a token scan, because of one block.** §4.6.2's refusal — "a template +referencing an undeclared variable is refused at save with the variable named" — reads as a scan for +`{{name}}`. It is not sufficient. `email.itemList.variable` holds a **bare name** (`items`), because the +block iterates the value rather than interpolating it; a token scan sees nothing there. A digest pointed +at `itmes` would have saved clean and arrived empty, which is the one variable mistake a reader of the +template cannot see. Blocks now optionally declare `variables(props)` in the registry — `itemList` is the +only one that does — and `emailBlocks/variables.js` walks tokens *and* declarations across the subject, +the text override and every block prop. The editor makes that field a `