docs: the engagement workstream — cutover 1 of 7 (edge → main)
#200
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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_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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) |
|
| [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 |
|
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
|
||||||
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
||||||
|
|||||||
142
android/PLAN.md
142
android/PLAN.md
@@ -659,7 +659,7 @@ not rank).
|
|||||||
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
|
| **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) |
|
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
|
||||||
| Contact | everyone | `/public/contact` |
|
| 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/*` |
|
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
||||||
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |
|
| 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.
|
and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.
|
||||||
|
|
||||||
### 6.3 Player self-service & game data (bearer)
|
### 6.3 Player self-service & game data (bearer)
|
||||||
- **Account** — `GET /player/account`; `PATCH /player/account/username`;
|
- **Account** — `GET /auth/me/account`; `PATCH /auth/me/account/username`;
|
||||||
`PATCH /player/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`.
|
`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),
|
- **Game account linking** — `POST /player/shard/link` (one-time `[link` code),
|
||||||
`POST /player/shard/account` (hybrid signup, when enabled), `GET /player/shard/accounts`.
|
`POST /player/shard/account` (hybrid signup, when enabled), `GET /player/shard/accounts`.
|
||||||
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
|
- **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.
|
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)
|
### 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*
|
Self-service account security lives under `/auth/me/account/*` — the canonical "me" endpoints for
|
||||||
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
|
every role, behind `requireAuth` (any authenticated role). The app calls these regardless of role and
|
||||||
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
|
never touches `/admin`.
|
||||||
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
|
> **Update (2026-08-29).** When this was decided it was an *additive* change: `/player/account/*`
|
||||||
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
|
> (gated to `role='player'`) and `/admin/account/*` served the same `account.controller` handlers and
|
||||||
authenticated role), so there's no logic duplication.
|
> 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,
|
> **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)
|
> 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
|
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
|
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.
|
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).**
|
1. **Role-agnostic self-service under `/auth/**` (§6.4, decided).**
|
||||||
✅ **DONE (2026-07-19, RunicGateway/website#76 (+ this docs PR)).** A `me.routes.js`
|
✅ **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
|
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
|
`/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:
|
`/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` — current `{ id, username, role }` (already existed; the app's role source).
|
||||||
- `GET /auth/me/account` — full self account.
|
- `GET /auth/me/account` — full self account.
|
||||||
- `PATCH /auth/me/account/username`, `PATCH /auth/me/account/password`.
|
- `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"
|
> `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.
|
> `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)
|
## 12. Build & CI (Gitea Actions)
|
||||||
|
|
||||||
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
|
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
|
||||||
|
|||||||
@@ -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 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.
|
- Every response carries an **`X-UOLink-Version: 5`** header.
|
||||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 4`.
|
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
|
||||||
- **Optionally**, send `X-UOLink-Version: 4` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||||
|
|
||||||
```json
|
```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.
|
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
|
so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once
|
||||||
it declares `4`.
|
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
|
**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
|
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
|
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.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
||||||
| `mob.logout` | `who` |
|
| `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
|
#### Economy & commerce
|
||||||
| kind | fields | notes |
|
| kind | fields | notes |
|
||||||
@@ -204,15 +220,36 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
|||||||
#### Housing / IDOC
|
#### Housing / IDOC
|
||||||
| kind | fields |
|
| 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.
|
`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
|
```json
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
"map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace",
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
"ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
|
||||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
"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)
|
#### Economy supply (periodic)
|
||||||
@@ -523,14 +560,46 @@ shop name or location actually change.
|
|||||||
|
|
||||||
| kind | fields | notes |
|
| 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. |
|
| `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
|
```json
|
||||||
{"kind":"vendor.listing","serial":"0x40001234",
|
{"kind":"vendor.listing","serial":"0x40001234",
|
||||||
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||||||
|
"ownerAcct":"darrow_acct",
|
||||||
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||||||
"region":"Britain","house":"Darrow's Villa"},
|
"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,
|
"count":2,"total":2,"truncated":false,
|
||||||
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
||||||
"price":25000,"name":null,"cliloc":1023721},
|
"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:
|
A typical character page:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" };
|
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" };
|
||||||
|
|
||||||
// 1. render the roster
|
// 1. render the roster
|
||||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||||
|
|||||||
25
link/PLAN.md
25
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 online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
||||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
||||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
| 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. |
|
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
||||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
| 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. |
|
| **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 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 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. |
|
| 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.**
|
> ⚠️ `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 |
|
| Signal | Hook | Freq | Notes |
|
||||||
|--------|------|:----:|-------|
|
|--------|------|:----:|-------|
|
||||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
| 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<Serial, DecayLevel>` 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.
|
Hold a `Dictionary<Serial, DecayLevel>` 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<Serial, DecayLevel>` 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.
|
- **`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`.
|
- 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`.
|
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
|
### 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.
|
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)
|
### Outbound (shard → sidecar)
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
279
link/v5.md
Normal file
279
link/v5.md
Normal file
@@ -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, `<servuo>`, 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.
|
||||||
@@ -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
|
`/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
|
stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened
|
||||||
`ownerAcct`. All are now stripped for every caller below admin.
|
`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."*
|
||||||
|
|||||||
@@ -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
|
> 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.
|
> 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
|
## Why the auth merge is out
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ Skeleton from the spec, with a small number of justified additions marked **(+)*
|
|||||||
>
|
>
|
||||||
> `users`, `account`, `invites`, `auth/providers` (PR 1, 28 routes), `moderation`, `bot-activity`,
|
> `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`,
|
> `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
|
> `uo-link`, `email`, `discord-bot`, `settings`, `dashboard`/`site-mode` (PR 4, 33 routes at the time;
|
||||||
|
> `email` has since lost its two Gmail connect routes, §7) each live
|
||||||
> in their own router under `admin/`, behind `admin/index.js`. PR 5 did the same for `public/` (24),
|
> 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/` (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
|
> `player.routes.js` and `auth.routes.js` are all deleted**; each group is now a directory whose
|
||||||
@@ -80,13 +81,26 @@ server/
|
|||||||
token is its own authority, so it
|
token is its own authority, so it
|
||||||
bypasses player_registration
|
bypasses player_registration
|
||||||
password.router.js (3) /auth/password/forgot + reset/:token
|
password.router.js (3) /auth/password/forgot + reset/:token
|
||||||
|
emailVerify.router.js (2) /auth/email/verify/:token — public and
|
||||||
|
token-gated like password.router: the
|
||||||
|
link arrives in a mailbox, so the
|
||||||
|
REQUEST half is at /auth/me/account/email
|
||||||
|
and only the CONFIRM half is here
|
||||||
session.router.js (2) POST /logout and GET /me — the two
|
session.router.js (2) POST /logout and GET /me — the two
|
||||||
singletons owning no path segment, so
|
singletons owning no path segment, so
|
||||||
mounted at the group root, LAST: the
|
mounted at the group root, LAST: the
|
||||||
/me sub-routers below also match the
|
/me sub-routers below also match the
|
||||||
bare /me and supply its noindex header
|
bare /me and supply its noindex header
|
||||||
me.routes.js (23) /auth/me/account*, sessions, trusted
|
me.routes.js (23) /auth/me/account*, sessions, trusted
|
||||||
devices — router-level requireAuth
|
devices — router-level requireAuth.
|
||||||
|
The ONLY self-service account surface
|
||||||
|
(see below); account.controller.js
|
||||||
|
sits beside it and is reached from
|
||||||
|
nowhere else
|
||||||
|
account.controller.js the self-service handlers: username,
|
||||||
|
password, TOTP, identities, device
|
||||||
|
sessions, trusted devices, recovery
|
||||||
|
codes
|
||||||
notifications.routes.js (3) /auth/me/devices*, notifications/*
|
notifications.routes.js (3) /auth/me/devices*, notifications/*
|
||||||
mobile.routes.js + /auth/mobile/* — native bearer login
|
mobile.routes.js + /auth/mobile/* — native bearer login
|
||||||
mobileSso.routes.js (5)
|
mobileSso.routes.js (5)
|
||||||
@@ -120,9 +134,6 @@ server/
|
|||||||
player/ index.js owns the shared `noindex, requireAuth` gate
|
player/ index.js owns the shared `noindex, requireAuth` gate
|
||||||
(authenticated, ANY role — staff are a superset
|
(authenticated, ANY role — staff are a superset
|
||||||
of players) and the mount table
|
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.router.js (4) /player/appeals
|
||||||
appeals.controller.js
|
appeals.controller.js
|
||||||
(/player/shard is module-uo's)
|
(/player/shard is module-uo's)
|
||||||
@@ -144,7 +155,6 @@ server/
|
|||||||
own prefixes; owns the shared
|
own prefixes; owns the shared
|
||||||
`noindex, isLoggedIn, staffOnly` gate and
|
`noindex, isLoggedIn, staffOnly` gate and
|
||||||
declares no routes itself
|
declares no routes itself
|
||||||
account.router.js (6) /admin/account — self-service, no adminOnly
|
|
||||||
users.router.js (9) /admin/users — adminOnly. The six
|
users.router.js (9) /admin/users — adminOnly. The six
|
||||||
/users/:id/shard/* routes are a
|
/users/:id/shard/* routes are a
|
||||||
MODULE's, reached through the
|
MODULE's, reached through the
|
||||||
@@ -170,8 +180,24 @@ server/
|
|||||||
from a manifest URL, enable,
|
from a manifest URL, enable,
|
||||||
disable, uninstall, purge, restart
|
disable, uninstall, purge, restart
|
||||||
and the source allowlist
|
and the source allowlist
|
||||||
email.router.js (6) /admin/email — Gmail OAuth2
|
engagement.router.js (22) /admin/engagement — adminOnly,
|
||||||
delivery — adminOnly
|
the declared event catalog (three
|
||||||
|
table-free reads, served from the
|
||||||
|
module registries) plus the rules
|
||||||
|
and audience segments an operator
|
||||||
|
configures over it, the count-only
|
||||||
|
reach preview, the message
|
||||||
|
templates and their sandboxed
|
||||||
|
preview / test send, and the send
|
||||||
|
log (G15). Two of these are POSTs
|
||||||
|
that write nothing: preview and
|
||||||
|
test-send act on the draft in the
|
||||||
|
request, not the stored row
|
||||||
|
email.router.js (4) /admin/email — outbound mail:
|
||||||
|
transport + credentials + send
|
||||||
|
test — adminOnly. The two
|
||||||
|
/connect/* routes went with Gmail
|
||||||
|
OAuth2 (§7)
|
||||||
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
discordBot.router.js (2) /admin/discord-bot — adminOnly
|
||||||
settings.router.js (4) /admin/settings — adminOnly. The
|
settings.router.js (4) /admin/settings — adminOnly. The
|
||||||
DELETE /:key is "reset to default"
|
DELETE /:key is "reset to default"
|
||||||
@@ -207,7 +233,8 @@ server/
|
|||||||
utils/
|
utils/
|
||||||
auth.js JWT sign/verify, isLoggedIn middleware
|
auth.js JWT sign/verify, isLoggedIn middleware
|
||||||
db.js MariaDB pool + ensureSchema()
|
db.js MariaDB pool + ensureSchema()
|
||||||
mailer.js (+) nodemailer; mailto fallback if SMTP unset
|
mailer.js (+) nodemailer over a registered transport;
|
||||||
|
mailto fallback when unconfigured (§7)
|
||||||
client/ built in Phase 2/3 (React + Vite)
|
client/ built in Phase 2/3 (React + Vite)
|
||||||
Dockerfile
|
Dockerfile
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
@@ -231,11 +258,43 @@ listed models/two utils. They're isolated in `middleware/` + one `activity` mode
|
|||||||
| col | type | notes |
|
| col | type | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
| username | VARCHAR(32) UNIQUE NOT NULL | |
|
| username | VARCHAR(32) UNIQUE NOT NULL COLLATE utf8mb4_general_ci | the `_ci` collation is the case-insensitive uniqueness backstop |
|
||||||
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
|
| password_hash | VARCHAR(72) **NULL** | bcrypt; **never** returned by the API. Nullable: an SSO-provisioned account has none until it sets one, and a NULL hash makes password login impossible |
|
||||||
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
|
| role | ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin' | |
|
||||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
| email | VARCHAR(255) NULL | the account's one contact address and the destination for password-reset mail. **Unique since engagement Phase 1b — but the index is on `email_norm`, never on this column** (below) |
|
||||||
| last_login_at | DATETIME NULL | shown in user management |
|
| email_norm | VARCHAR(255) COLLATE utf8mb4_bin **GENERATED** `AS (LOWER(email)) STORED`, UNIQUE | the uniqueness key. Every `_ci` collation MariaDB offers is also **accent**-insensitive, so a UNIQUE index on `email` would refuse `jose@x.com` once `josé@x.com` existed — two different mailboxes. `LOWER()` under `_bin` folds case without folding accents. Keeping the fold in a generated column rather than in application code means no caller can bypass it. Multiple NULLs stay legal, which is what lets the de-duplication clear an address without deleting an account |
|
||||||
|
| email_verified | TINYINT(1) NOT NULL DEFAULT 0 | set only by opening a verification link (or by an invite, which proves the address by construction). SSO sets it from the IdP's actual `email_verified`/`verified` claim — **not** from the mere presence of an address, which is what it used to do |
|
||||||
|
| email_pending | VARCHAR(255) NULL | an address requested but not yet proved. It does **not** displace `email`, so a mistyped address cannot silently redirect account-recovery mail. Deliberately **not** unique: a pending address reserves nothing, and the UNIQUE index above arbitrates at confirmation time |
|
||||||
|
| status | ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active' | lifecycle, independent of role; enforced in `requireAuth` + login |
|
||||||
|
| totp_secret / totp_enabled | VARCHAR(64) NULL / TINYINT(1) | opt-in 2FA |
|
||||||
|
| tokens_valid_after | DATETIME NULL | session-revocation cutoff; bumped on password change / "log out everywhere" |
|
||||||
|
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | also the **tie-break for de-duplication**: oldest account keeps a shared address |
|
||||||
|
| last_login_at / last_login_ip | DATETIME NULL / VARCHAR(45) NULL | shown in user management |
|
||||||
|
|
||||||
|
### email_verifications *(engagement Phase 1b)*
|
||||||
|
Same shape as `password_resets`, deliberately — an opaque random token whose **sha256 only** is stored, single-use, ~24h.
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
|
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 of the opaque token; a DB read never yields a usable link |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| email | VARCHAR(255) NOT NULL | **the address this token proves.** On the row, not read from the user at confirm time: a token proves control of the address it was mailed to and nothing else, so a later request for a different address cannot be confirmed by an older link |
|
||||||
|
| status | ENUM('pending','used') NOT NULL DEFAULT 'pending' | consumed atomically |
|
||||||
|
| requested_ip | VARCHAR(64) NULL | audit only |
|
||||||
|
| expires_at / created_at / used_at | DATETIME | |
|
||||||
|
|
||||||
|
### email_dedupe_report *(engagement Phase 1b)*
|
||||||
|
Who lost an address when addresses became unique. Written by `schema.sql`'s migration in pure SQL — `ensureSchema()` runs that file statement-by-statement and there is no JS migration hook — and only ever read afterwards.
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
|
| user_id | INT NOT NULL, UNIQUE | the UNIQUE is what makes the migration's `INSERT IGNORE` strictly idempotent |
|
||||||
|
| username | VARCHAR(32) NOT NULL | captured at clear time |
|
||||||
|
| lost_address | VARCHAR(255) NOT NULL | the report is the only place this value survives |
|
||||||
|
| cleared_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||||
|
| acknowledged_at | DATETIME NULL | set when an admin dismisses the dashboard warning; rows are kept as the record of what the upgrade did |
|
||||||
|
|
||||||
|
No FK to `users`, on purpose — same reasoning as `posts.announce_job_id`: a constraint re-added on every boot is a constraint that can fail a boot, and this is a historical record rather than a live relation.
|
||||||
|
|
||||||
### posts — one table, four categories
|
### posts — one table, four categories
|
||||||
| col | type | notes |
|
| col | type | notes |
|
||||||
@@ -408,6 +467,342 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
|||||||
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
`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.
|
replaces the whole set. Nothing is pushed unless the user subscribed.
|
||||||
|
|
||||||
|
Engagement phase 3 made this the **push projection** of `notification_channel_prefs` below. It keeps
|
||||||
|
its exact shape and stays what `utils/pushDispatch` reads — the shipped Android client cannot be
|
||||||
|
changed from this side — and the general table carries the channel dimension it lacks.
|
||||||
|
|
||||||
|
### notification_channel_prefs — which channel, in which mode (engagement phase 3)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| stream_id | VARCHAR(64) NOT NULL | a stream id **or** a trigger id — **one namespace** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.2), which is what keeps this key single-column |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | `email` / `push` / `inapp`, from the delivery-channel registry (`src/engagement/channels.js`) |
|
||||||
|
| mode | ENUM('off','instant','digest') NOT NULL DEFAULT 'off' | `digest` only where the channel declares `supportsDigest` |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`PRIMARY KEY(user_id, stream_id, channel)`, `INDEX(channel, mode)`.
|
||||||
|
|
||||||
|
**A row exists only where the user has expressed something, and absence is the *channel's* default,
|
||||||
|
not `off`.** That default lives in the channel registry and nowhere else (§3.1, G9: push, email and
|
||||||
|
in-app do not agree on it). All three currently declare `off`, so absence and off happen to coincide
|
||||||
|
today — a fact about the declarations, not about this table, and code must not assume it. The column
|
||||||
|
`DEFAULT` is the value a write with no mode takes, not the meaning of a missing row.
|
||||||
|
|
||||||
|
**It is a superset of `notification_subscriptions`, which becomes its push projection.** The shipped
|
||||||
|
Android client's wire shape is frozen (`{streams:[…]}`), so the old table stays exactly what
|
||||||
|
`utils/pushDispatch` reads and every write to either fans out to the other. The invariant both
|
||||||
|
directions maintain: **a `push` row with `mode <> 'off'` ⟺ a `notification_subscriptions` row.** An
|
||||||
|
explicit `off` is *stored* rather than deleted — folding "I turned this off" back into "I never said"
|
||||||
|
is only harmless while the default is off. Existing subscriptions are carried across by an
|
||||||
|
`INSERT IGNORE … SELECT` backfill in `schema.sql`, replay-safe on every boot like the
|
||||||
|
`announce_jobs → announce_job_legs` one it copies.
|
||||||
|
|
||||||
|
### engagement_rules — the operator's configuration (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | a declared trigger id. **No FK and no existence check** — a trigger is declared in code, so a rule naming one no module currently registers is *dormant*, never deleted ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.3) |
|
||||||
|
| name | VARCHAR(160) NOT NULL | |
|
||||||
|
| enabled | TINYINT(1) NOT NULL DEFAULT **0** | off by default, so no import, seed or restore can start mailing on its own (§7.1 Q3) |
|
||||||
|
| audience | VARCHAR(32) NOT NULL DEFAULT 'owner' | a ceiling name — `owner` / `staff` / `subscribers` / `members` / `authenticated` / `everyone` |
|
||||||
|
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
|
||||||
|
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
|
||||||
|
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
|
||||||
|
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5 |
|
||||||
|
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
|
||||||
|
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
|
||||||
|
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
|
||||||
|
| cancel_on | JSON NULL | trigger ids that cancel a pending row for the same subject |
|
||||||
|
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(trigger_id, enabled)` — the engine's one indexed read per emit.
|
||||||
|
|
||||||
|
**`audience_segment_id` carries no foreign key on purpose.** The two options a database offers are
|
||||||
|
both wrong here: `ON DELETE CASCADE` would delete an operator's rules, and `ON DELETE SET NULL` would
|
||||||
|
silently fall the rule back to its plain `audience` column — and that fallback reaches a **different
|
||||||
|
set of people**, which is the failure §5.1a rule 4 exists to prevent. A rule whose segment is gone is
|
||||||
|
dormant and sends nothing, and deleting a segment a rule still uses is refused in the model.
|
||||||
|
|
||||||
|
### engagement_audience_segments — operator-composed audiences (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| name | VARCHAR(160) NOT NULL | |
|
||||||
|
| expression | JSON NOT NULL | a boolean tree of module-declared audience ids + params |
|
||||||
|
| ceiling | VARCHAR(32) NOT NULL | **derived, never operator-typed** — the narrowest ceiling in the tree |
|
||||||
|
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
**Composition narrows, never widens.** `A OR B` takes the *tighter* of the two ceilings, not the
|
||||||
|
looser: a ceiling states what an expression is allowed to reach, not what it will resolve to, so the
|
||||||
|
boolean operator's direction is irrelevant. Two incomparable ceilings have no meet and the save is
|
||||||
|
refused rather than resolved to a guess (`src/modules/ceilings.js`). `not` is legal only inside an
|
||||||
|
`and` — a complement needs a set to be taken from, and "everyone except…" is a broadcast built out of
|
||||||
|
a narrow audience — and it contributes no ceiling of its own, since excluding people cannot widen.
|
||||||
|
|
||||||
|
The ceiling is a **stored column rather than a runtime computation** so an audit can read what a rule
|
||||||
|
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
|
||||||
|
ceiling cannot retroactively widen a segment saved under the old one.
|
||||||
|
|
||||||
|
### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
|
||||||
|
| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend |
|
||||||
|
| last_fired_at | DATETIME NOT NULL | |
|
||||||
|
| fire_count | INT NOT NULL DEFAULT 1 | |
|
||||||
|
|
||||||
|
`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `INDEX(last_fired_at)` for a prune.
|
||||||
|
|
||||||
|
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
|
||||||
|
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
|
||||||
|
(rule, user) alone silently drops three of them.
|
||||||
|
|
||||||
|
**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that
|
||||||
|
it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so
|
||||||
|
without the channel in the key the first channel of a rule claimed the cooldown and every later one
|
||||||
|
was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and
|
||||||
|
in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per
|
||||||
|
occasion: an operator who says "one a day about this house" means one mail and one inbox item.
|
||||||
|
|
||||||
|
Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a
|
||||||
|
key change** — replaying `schema.sql` on every boot would fail after the first run without the
|
||||||
|
`information_schema` guard that reads whether the key already carries the column. Rows written before
|
||||||
|
the migration keep `channel = ''` and expire on their own interval; dropping the table instead would
|
||||||
|
let a storm through the window.
|
||||||
|
|
||||||
|
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
|
||||||
|
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
|
||||||
|
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
|
||||||
|
connector's default `foundRows: true` makes a no-op update report 1 rather than 0 — under which every
|
||||||
|
cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
|
||||||
|
|
||||||
|
### engagement_outbox — the send queue (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
|
||||||
|
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
|
||||||
|
|
||||||
|
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
|
||||||
|
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
|
||||||
|
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
|
||||||
|
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
|
||||||
|
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
|
||||||
|
| attempts / last_error / sent_at | | |
|
||||||
|
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures |
|
||||||
|
|
||||||
|
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
|
||||||
|
`INDEX(rule_id, user_id, subject_key, status)`.
|
||||||
|
|
||||||
|
**The unique key is scoped, and a global one would have been a data-loss bug.** A dedupe key names the
|
||||||
|
*event*; one event legitimately becomes one row per (rule, user, channel), so a fifty-person audience
|
||||||
|
on two channels is a hundred rows carrying the same key. A global `UNIQUE(dedupe_key)` admits the first
|
||||||
|
and silently ignores the rest.
|
||||||
|
|
||||||
|
**A row is claimed with a compare-and-set** — `UPDATE … SET status='sending' WHERE id=? AND
|
||||||
|
status='scheduled'` — and the sweeper the server reports `affectedRows = 1` to owns it (§7.1 Q2). That
|
||||||
|
makes the outbox safe for two app instances; the other four workers in this codebase are still
|
||||||
|
single-instance, so the deployment as a whole is not. A row stranded in `sending` by a crashed process
|
||||||
|
is reclaimed after a window, because `status='scheduled'` would otherwise never match it again.
|
||||||
|
|
||||||
|
### engagement_sends — the send log (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| outbox_id | BIGINT NULL | |
|
||||||
|
| rule_id | INT NULL | |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | |
|
||||||
|
| user_id | INT NULL FK→users(id) **ON DELETE SET NULL** | the log survives an account deletion |
|
||||||
|
| channel / transport | VARCHAR(32) | which channel, and which mail transport actually carried it |
|
||||||
|
| address_hash | CHAR(64) NULL | sha256 — enough to correlate a bounce (Phase 9), useless as a mailing list |
|
||||||
|
| status | ENUM('sent','failed','suppressed','bounced','complained') | |
|
||||||
|
| detail | VARCHAR(500) NULL | |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(trigger_id, created_at)`, `INDEX(user_id, created_at)`, `INDEX(rule_id, created_at)` — the last
|
||||||
|
of those is the per-rule hourly ceiling's count, which runs once per rule per event.
|
||||||
|
|
||||||
|
G15: "did user X get the mail?" has never been answerable on this deployment. A row is written for
|
||||||
|
**every terminal outcome**, not only success — "no, and here is why" is an answer this table has to be
|
||||||
|
able to give — and the hourly ceiling counts only `sent`, so a broken transport cannot silently consume
|
||||||
|
a rule's budget and mute it.
|
||||||
|
|
||||||
|
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
|
||||||
|
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
|
||||||
|
counts only.
|
||||||
|
|
||||||
|
Phase 9 gave two of those statuses their first writers. `suppressed` means the address was on the
|
||||||
|
suppression list and **no transport call was made**; `bounced` means one was, and the mailbox does
|
||||||
|
not exist. `complained` still has none — it needs a provider feedback loop, which SMTP has not got.
|
||||||
|
|
||||||
|
### engagement_suppressions — addresses we have stopped mailing (engagement phase 9)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| address_hash | CHAR(64) NOT NULL PK | sha256 of the **lower-cased, trimmed** address |
|
||||||
|
| address_masked | VARCHAR(190) NULL | `d***@example.com`. Phase 9's one addition to the planned DDL |
|
||||||
|
| channel | VARCHAR(32) NOT NULL DEFAULT 'email' | |
|
||||||
|
| reason | ENUM('bounce','complaint','manual','unverified') | |
|
||||||
|
| detail | VARCHAR(500) NULL | e.g. `hard bounce: 5.1.1` |
|
||||||
|
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | the admin, for a manual row; **NULL for an automatic one**, which is what separates the two |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(created_at)`, `INDEX(reason, created_at)` — the screen's two orderings.
|
||||||
|
|
||||||
|
G16. **Keyed on the address, not the user**, and after Phase 1b made addresses unique that is a
|
||||||
|
choice rather than a workaround: a bounce arrives as an address, it does not know which account was
|
||||||
|
behind it, and it stays true after that account changed its address or was deleted.
|
||||||
|
|
||||||
|
Writes are `INSERT IGNORE`, so **the first reason an address was suppressed is the one that
|
||||||
|
survives** — an address that hard-bounced in March and was manually re-added in June still reads
|
||||||
|
`bounce`, because that is the fact explaining why the mail stopped. An upsert would let the most
|
||||||
|
recent write overwrite the diagnosis.
|
||||||
|
|
||||||
|
`address_masked` exists because a hash-only table cannot be operated; the reasoning and the routes
|
||||||
|
are in §7's *Deliverability* subsection.
|
||||||
|
|
||||||
|
### engagement_digest_state — how far each digest has got (engagement phase 6)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | |
|
||||||
|
| scope_key | VARCHAR(190) NOT NULL DEFAULT '' | `''` = deployment-wide; `team:12` = one Team. NOT NULL with a `''` default because it is a PRIMARY KEY column and MariaDB coerces a nullable one anyway — the same workaround `team_integration_config` and `teams.active_key` both carry |
|
||||||
|
| last_digest_at | DATETIME NULL | stamped **only on a successful send** |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`PRIMARY KEY (user_id, channel, scope_key)`, `INDEX(channel, last_digest_at)` — the worker's driving
|
||||||
|
question is "whose digest is due?", which is a range scan of that index rather than of every digest ever
|
||||||
|
sent.
|
||||||
|
|
||||||
|
**This is a digest's only state, and deliberately not a digest queue.** The engine writes an outbox row
|
||||||
|
per (rule, user, channel) at emit time carrying a snapshot of the payload; that is right for an instant
|
||||||
|
send and wrong for a digest, which is re-derived from the source tables when it goes out. Three
|
||||||
|
properties depend on the re-derivation: a two-day outage sends one digest rather than replaying two
|
||||||
|
days, a post hidden after it was written is not in the query, and — the security one — a user who lost
|
||||||
|
access between the post and the send is no longer in the recipient set. So `engine.subscribedTo`
|
||||||
|
enqueues **`instant` recipients only**.
|
||||||
|
|
||||||
|
Lifted out of `team_notification_prefs.last_digest_at`, which was a worker's column on a user's
|
||||||
|
preferences row; `schema.sql` backfills it with an `INSERT IGNORE … SELECT`, replay-safe by the primary
|
||||||
|
key rather than by a flag.
|
||||||
|
|
||||||
|
### engagement_templates — the message bodies (engagement phase 5a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| `key` | VARCHAR(96) NOT NULL **UNIQUE** | the stable id a rule's `template_keys` map and `mailer` name |
|
||||||
|
| name | VARCHAR(160) NOT NULL | what the admin list shows |
|
||||||
|
| trigger_id / trigger_version | VARCHAR(96) NULL / INT NULL | **no foreign key**, for the reason `engagement_rules.trigger_id` has none: a trigger is declared in code. NULL = a reusable template not tied to one trigger, which is what every transactional seed is |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | one template per channel; a rule names a set |
|
||||||
|
| subject | VARCHAR(300) NULL | email only, and it interpolates. NULL is how a non-email template says it has none |
|
||||||
|
| blocks | MEDIUMTEXT NOT NULL | a JSON block array, validated + sanitized on write against the `email.*` registry — never raw operator HTML |
|
||||||
|
| text_body | MEDIUMTEXT NULL | an authored plain-text part that **replaces** the generated one; NULL = generated from each block's `toText` |
|
||||||
|
| status | ENUM('draft','published') DEFAULT 'draft' | |
|
||||||
|
| protected | TINYINT(1) DEFAULT 0 | editable, not deletable — the `pages.protected` flag, for the same reason: the system breaks without a password-reset body |
|
||||||
|
| seed_key / seed_version / customized | VARCHAR(96) NULL / INT NULL / TINYINT(1) DEFAULT 0 | the "ship a better default without stealing an operator's work" mechanism — see below |
|
||||||
|
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(trigger_id, channel, status)`, `INDEX(seed_key)`.
|
||||||
|
|
||||||
|
**The three seed columns are one mechanism, and the guard lives in SQL.** On boot the seeder runs an
|
||||||
|
`INSERT IGNORE` per shipped template and, when the row already exists, a single
|
||||||
|
`UPDATE … WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A read-then-write would leave a
|
||||||
|
window in which a concurrent boot overwrites an edit an operator made a moment earlier; putting
|
||||||
|
`customized = 0` in the UPDATE's own WHERE closes it. (MariaDB's `ON DUPLICATE KEY UPDATE` cannot carry
|
||||||
|
a WHERE, which is why this is two statements rather than the upsert ENGAGEMENT.md §4.6.1 sketches.) A
|
||||||
|
customized row whose shipped default has moved on is **surfaced**, never applied.
|
||||||
|
|
||||||
|
**A missing or unusable row renders the shipped default rather than nothing.** `renderByKey` falls back
|
||||||
|
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
|
||||||
|
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
|
||||||
|
what makes it safe for a password-reset mail to depend on this table at all.
|
||||||
|
|
||||||
|
### user_notifications — the in-app inbox (engagement phase 7)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | CASCADE, unlike `engagement_sends`: this is content addressed to a person, not an audit of what the deployment sent |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | denormalized, **no foreign key** — a trigger is declared in code |
|
||||||
|
| title | VARCHAR(300) NOT NULL | rendered from the template's first `email.heading`; falls back to the projected `title`, then to the key. Truncated rather than refused |
|
||||||
|
| body | TEXT NULL | the **text** render of the template's remaining blocks. Not the email HTML — see below |
|
||||||
|
| url | VARCHAR(500) NULL | **site-relative only**, validated with the same character class `pageUrlTemplate` and the engine's `url` variables use. An absolute url on this deployment's own base is reduced to a relative one; anything else is dropped to NULL |
|
||||||
|
| dedupe_key | VARCHAR(190) NULL | NULL = this item does not dedupe |
|
||||||
|
| read_at | DATETIME NULL | |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
`UNIQUE (user_id, dedupe_key)`, `INDEX(user_id, read_at, created_at)`, `INDEX(created_at)`.
|
||||||
|
|
||||||
|
**The unique key is scoped to the USER, and that is deliberately narrower than the outbox's.**
|
||||||
|
`engagement_outbox` scopes its dedupe to `(rule, user, channel)` because one event legitimately becomes
|
||||||
|
one row per channel; an inbox has no channel dimension, so two rows for one event would be one item
|
||||||
|
shown twice. Multiple NULLs are permitted by a UNIQUE index, which is what "does not dedupe" means, and
|
||||||
|
`INSERT IGNORE` is what makes a replay, a retry and a module writing the same item twice all one no-op.
|
||||||
|
|
||||||
|
**`body` is text, and that is the load-bearing choice rather than a shortcut.** The `email.*` renderer
|
||||||
|
produces markup built for mail clients — table rows, inline hex colours, a light-only palette declared
|
||||||
|
with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a pale card
|
||||||
|
floating in a dark one. `toText` is the same content with none of that, and it is the part the block
|
||||||
|
contract already promises every block can produce. It also means there is no operator markup on this
|
||||||
|
surface to sanitize, and no way for one to appear: every renderer treats the column as text.
|
||||||
|
|
||||||
|
**The template maps onto the three columns by block ROLE** (`templates.renderInappByKey`): the first
|
||||||
|
`email.heading` is the title, the first `email.button` is the url, and everything else is the body. So
|
||||||
|
an operator editing `inapp.event` in the Phase 5b editor changes what appears in the inbox, which is the
|
||||||
|
only reason the template exists at all.
|
||||||
|
|
||||||
|
**Retention: `utils/userNotificationsPrune.js`, nightly, READ items only.** Age alone would delete the
|
||||||
|
evidence for "I was never told", which is the complaint this table answers, and an inbox that quietly
|
||||||
|
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
|
||||||
|
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
|
||||||
|
worker that file is modelled on.
|
||||||
|
|
||||||
|
### The two block registries — pages and mail (engagement phase 5a)
|
||||||
|
|
||||||
|
`server/src/blocks/` (the CMS page family) and `server/src/emailBlocks/` (`email.heading`, `email.text`,
|
||||||
|
`email.button`, `email.divider`, `email.image`, `email.itemList`) are **siblings, not one registry**.
|
||||||
|
Three reasons, in order of what they cost if ignored:
|
||||||
|
|
||||||
|
1. **Email blocks render on the server.** A page block carries `schema` / `sanitize` / `cacheTTL` and is
|
||||||
|
drawn by React in `client/src/blocks/`; a mail body is a string this process produces, so an email
|
||||||
|
definition carries `toHtml` and `toText`. `registerBlock` freezes a fixed field set and would drop
|
||||||
|
both silently.
|
||||||
|
2. **One registry would be one namespace.** The page registry's only server consumer is
|
||||||
|
`pages.model.js`; putting `email.heading` in that Map makes a CMS page containing an email block
|
||||||
|
validate and save, with nothing on the client able to draw it.
|
||||||
|
3. The entry shapes differ — `cacheTTL` and `container` mean nothing to a mail body, a renderer nothing
|
||||||
|
to a cached page block.
|
||||||
|
|
||||||
|
What *is* shared is shared by binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
|
||||||
|
(`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`) are factories the two
|
||||||
|
registries each bind. ENGAGEMENT.md §4.4's "do not build a second editor" is honoured where it is about
|
||||||
|
the editor — Phase 5b drives the `email.*` family through the existing block/prop-panel machinery.
|
||||||
|
|
||||||
|
### Template variables — the token grammar (engagement phase 5a)
|
||||||
|
|
||||||
|
`{{ name }}`, a bare declared variable name, and nothing else: no filters, no conditionals, no loops, no
|
||||||
|
dotted paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is why
|
||||||
|
the grammar needs no loop. Three consequences worth knowing before authoring one:
|
||||||
|
|
||||||
|
- **Interpolation is HTML-escaped in the HTML part and raw in the text part.** There is no raw-HTML
|
||||||
|
variable type (§4.6.2) — a module supplies data, not markup.
|
||||||
|
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
|
||||||
|
about where it points; a substituted value that is not http(s)/same-origin loses its href and renders
|
||||||
|
as inert text rather than as a link a reader has no reason to distrust.
|
||||||
|
- **Presentational conditionals live at the call site**, not in the template. `mailer` computes
|
||||||
|
` for the account “Darrow”` with a ternary and passes the *result* as a variable, whose declared
|
||||||
|
`example` shows exactly what it produces.
|
||||||
|
|
||||||
|
Four **ambient** variables — `siteName`, `siteUrl`, `logoUrl`, `year` — are available to every template
|
||||||
|
and are merged **over** whatever a caller passes. A caller supplies the message; the deployment supplies
|
||||||
|
its identity, and letting a caller override it would mean mail that claims to be from somewhere else.
|
||||||
|
|
||||||
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
### 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
|
Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They
|
||||||
@@ -762,13 +1157,18 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
|||||||
| 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 | `/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) |
|
| 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 |
|
| 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`. |
|
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to the active account on the address; **always** returns the same generic 200 (no account enumeration). Addresses are unique since Phase 1b, so this matches at most one account. Reset mail is deliberately **not** gated on `email_verified` — that gate governs opt-in engagement mail, and applying it to account recovery would lock out every user carrying an address from before verification existed. Logs `account.password.reset.request`. |
|
||||||
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
| 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`. |
|
| 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`) |
|
| GET | `/email/verify/:token` | — | — | validate an email-confirmation link → `{username, email}` for the page, else 404 |
|
||||||
|
| POST | `/email/verify/:token` | — (rate-limited) | — | consume the single-use link, install `email_pending` as `email` and set `email_verified`. **Issues no session** — it proves control of a mailbox, not of an account. Unauthenticated on purpose: the link is opened from a mailbox, routinely on a device with no session, and the token is the proof. **Answers 404 for an unusable link AND for an address another account confirmed first**, deliberately — the two must be indistinguishable, or the endpoint becomes an oracle for which addresses hold accounts. Logs `account.email.verified`. |
|
||||||
|
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, email_verified, email_pending, 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/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 |
|
| 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 |
|
| 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 |
|
||||||
|
| PATCH | `/me/account/email` | cookie / bearer (rate-limited, **password step-up**) | `{email, currentPassword?}` | request an address. **Stages it in `email_pending`; `email` is untouched**, so the account keeps receiving password-reset mail at the address it already has until the emailed link is opened — a typo cannot redirect account recovery. `currentPassword` is required when the account has one (an address is where recovery lands); an SSO-provisioned account with a null hash is exempt, the same carve-out `/me/account/password` makes. Returns `{email_pending, emailed, reason}` — `emailed:false` is reported honestly rather than pretending, because the caller typed this address themselves and there is no enumeration reason to hide it. **429** past the per-user send ceiling |
|
||||||
|
| POST | `/me/account/email/resend` | cookie / bearer (rate-limited) | — | re-send the link for the staged address; **400** when nothing is pending |
|
||||||
|
| DELETE | `/me/account/email/pending` | cookie / bearer | — | abandon the staged address **and retire its outstanding links**, so a confirmation email already delivered can no longer install it |
|
||||||
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
| 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) |
|
| 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 |
|
| 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 |
|
||||||
@@ -779,23 +1179,36 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
|||||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
| 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 | `/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/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
||||||
|
| GET · PUT | `/me/notifications/channels` | cookie / bearer | `{prefs:[{id,channel,mode}]}` on PUT | get / update own **per-channel** preferences ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5, phase 3). Returns the delivery-channel registry (`email`/`push`/`inapp`, each with `defaultMode`, `supportsDigest`, `modes`) plus one item per subscribable id — the **union** of push streams and event triggers, one namespace (§7.2) — carrying the **effective** mode on each channel that applies to it. A trigger-only id has no `push` toggle; a mode with no stored row reads as that channel’s default, so a client never sees which is which. The PUT is **sparse**: only the `(id, channel)` pairs listed are written and every other pair is untouched, so setting `email` cannot disturb `push`. `off` is a mode, never an omission — which is why this endpoint has no required-empty-array case. Entries naming an unknown id, an inapplicable channel or a mode that channel does not accept are **dropped, not refused**; the full stored state is echoed back. A `push` entry is mirrored into `/me/notifications/subscriptions`, whose wire shape is unchanged |
|
||||||
| 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) |
|
| 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) |
|
||||||
|
| GET | `/me/notifications` | cookie / bearer | `?limit&before&unread` | **one page of the caller's in-app inbox** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5 G17, phase 7), newest first. `before` is a **keyset cursor** (the previous page's last id), never an offset: the list gains rows at the top while it is being read. `limit` defaults to 30, capped at 100. Carries `unread`, the count for the whole inbox rather than the page, so a client rendering both a list and a badge cannot show them disagreeing. **No parameter names a user** — the caller is the only account any of these four routes can read |
|
||||||
|
| GET | `/me/notifications/unread-count` | cookie / bearer | — | `{unread}`. Its own route because it is **polled**: asking "is there anything new" must not make the server assemble a page of bodies to answer with one integer |
|
||||||
|
| POST | `/me/notifications/:id/read` | cookie / bearer | — | mark one item read. **Idempotent** — the statement carries `read_at IS NULL`, so a second call does not move the stamp. **404 both** when no such item exists and when it belongs to another account: the same answer on purpose, so this cannot be used to ask whether an id is anybody's |
|
||||||
|
| POST | `/me/notifications/read-all` | cookie / bearer | — | mark the whole inbox read; returns `{ok, changed, unread:0}` |
|
||||||
|
|
||||||
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
|
||||||
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
authenticated role, behind `requireAuth` **only** — any active account, never a specific role. A
|
||||||
(no logic duplication) behind `requireAuth` **only** — any active account, never a specific role. This
|
client (the Android app) manages its own account through it without ever touching `/admin`
|
||||||
lets a client (the Android app) manage its own account through one surface without ever touching
|
(docs/android/PLAN.md §6.4).
|
||||||
`/admin` (docs/android/PLAN.md §6.4). The older `/player/account/*` + `/admin/account/*` routes stay
|
|
||||||
for web back-compat.
|
It used to be the third of three URL surfaces onto `account.controller`, beside `/player/account/*`
|
||||||
|
and `/admin/account/*`. **Those 14 routes were deleted.** Both were strictly *smaller* than this one —
|
||||||
|
neither carried recovery codes, and `/admin/account` carried no username or password change — so the
|
||||||
|
web client already reached in here for part of a single screen. Gating was equivalent where it
|
||||||
|
overlapped (`/player` and `/auth/me` are byte-identical `noindex, requireAuth`; `staffOnly` on
|
||||||
|
`/admin/account` was strictly narrower and bought nothing, since every handler is self-scoped to
|
||||||
|
`req.user.id`). The controller moved to `router/v1/auth/account.controller.js` beside its one
|
||||||
|
remaining router. **New self-service fields go here and only here.**
|
||||||
|
|
||||||
**The `/player/*` group is self-service, not player-only.** Staff are a **superset** of players — every
|
**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`,
|
player ability plus their staff tools on top — so the whole group (`appeals.router.js`, mounted by
|
||||||
`appeals.router.js`, mounted by `player/index.js`, plus whatever a module mounts here) sits behind
|
`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/
|
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
|
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
|
`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
|
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
|
under `/admin/shard/*`, so the two are interchangeable. (Core no longer does this for account
|
||||||
|
security — see `/auth/me/*` above — but the rule the module depends on is unchanged.) 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.
|
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
|
`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in
|
||||||
@@ -818,8 +1231,27 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
|
|||||||
**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
|
**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-
|
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
|
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
|
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and — from
|
||||||
create/publish-post path for `news.post`. The catalog is assembled at boot by
|
**ENGAGEMENT.md Phase 11** — the **engagement engine** for `news.post`.
|
||||||
|
|
||||||
|
> **The news publish path changed in Phase 11 and it is an operator-visible change.** Publishing a
|
||||||
|
> news post used to call `pushDispatch.publish('news.post', …)` directly from
|
||||||
|
> `admin.controller.js`'s `announceIfNewlyPublished`; it now calls
|
||||||
|
> `utils/newsNotify.emitNewsPost`, which emits core's declared `news.post` trigger and lets the engine
|
||||||
|
> decide. Push therefore rides a **rule** like every other channel, and core seeds that rule
|
||||||
|
> `enabled = 0` beside the four Team ones — so **news push stops on upgrade** until an operator enables
|
||||||
|
> it in Admin → Engagement → Rules, where a banner says so. The `news.post` seed carries its own
|
||||||
|
> one-shot settings key (`engagement_news_rule_seeded`) rather than joining the Team group's, because
|
||||||
|
> the Team key is already stamped on exactly the deployments this affects.
|
||||||
|
>
|
||||||
|
> **The other two things a publish fires are untouched.** `announceJobs.enqueueIfNeeded` (a one-shot
|
||||||
|
> delivery to a channel of the deployment — the in-game town crier, Discord #news, with retry) and
|
||||||
|
> `registries.dispatchPostHook('onSaved')` (idempotent state mirroring, which also runs on delete) are
|
||||||
|
> different kinds of thing and still fire exactly as they did. The emit is gated on the same
|
||||||
|
> `enqueueIfNeeded` job id the push was gated on — the single "newly published news" transition
|
||||||
|
> signal — so an edit or a re-publish still does not re-fire.
|
||||||
|
|
||||||
|
The catalog is assembled at boot by
|
||||||
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
|
`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
|
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-uo` in Phase 3 and are registered by it; their ids are grandfathered to that module
|
||||||
@@ -923,13 +1355,14 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
| GET | `/posts/:category/:idOrSlug` | single published post |
|
| GET | `/posts/:category/:idOrSlug` | single published post |
|
||||||
| GET | `/wiki` | list of pages (slug + title) |
|
| GET | `/wiki` | list of pages (slug + title) |
|
||||||
| GET | `/wiki/:slug` | single page |
|
| GET | `/wiki/:slug` | single page |
|
||||||
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
|
| POST | `/contact` | (rate-limited) send mail via the configured transport (§7); if unconfigured, respond `{fallback:"mailto", email}` |
|
||||||
| GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module |
|
| GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module |
|
||||||
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
|
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
|
||||||
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
||||||
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
||||||
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
|
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
|
||||||
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
|
| POST · GET | `/engagement/unsubscribe/:token` | one-click unsubscribe (engagement phase 6, [`ENGAGEMENT.md`](ENGAGEMENT.md)). **The only write in this tier and the only routes with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC naming a **channel and a scope**, and its whole capability is "turn that channel off for that scope, for one account": it reads nothing, cannot turn anything back on, and names no other scope. POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, scope) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to unsubscribe people who asked for nothing |
|
||||||
|
| POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push |
|
||||||
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
||||||
|
|
||||||
Public content GETs pass through the **siteMode** gate (§5).
|
Public content GETs pass through the **siteMode** gate (§5).
|
||||||
@@ -952,7 +1385,7 @@ These rows are configuration that happens to need a login.
|
|||||||
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
|
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
|
||||||
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
|
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
|
||||||
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
|
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
|
||||||
the whole gate. The ops/config capabilities — `modules`, `email`, `discord-bot`, `settings`, and
|
the whole gate. The ops/config capabilities — `modules`, `email`, `engagement`, `discord-bot`, `settings`, and
|
||||||
`PUT /site-mode` — are `adminOnly`. There is no residual file: every admin route is declared in a
|
`PUT /site-mode` — are `adminOnly`. There is no residual file: every admin route is declared in a
|
||||||
capability router.
|
capability router.
|
||||||
|
|
||||||
@@ -973,7 +1406,7 @@ never shown a control that would 403. The URLs below are unaffected by which
|
|||||||
file a route sits in — that is the property the route manifest freezes.
|
file a route sits in — that is the property the route manifest freezes.
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity, and `warnings[]` — operator conditions that are quietly not working and would otherwise be discovered by somebody not receiving an email. Normally empty. Each entry is `{code, message, href}` and each one is computed defensively: a warning that can 500 the admin landing page is a worse bug than the one it reports. Today there is one, `EMAIL_TRANSPORT_MIGRATION` (§7) |
|
||||||
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
|
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
|
||||||
| GET | `/posts?category=` | all posts incl. unpublished |
|
| GET | `/posts?category=` | all posts incl. unpublished |
|
||||||
| POST | `/posts` | create |
|
| POST | `/posts` | create |
|
||||||
@@ -1000,6 +1433,23 @@ file a route sits in — that is the property the route manifest freezes.
|
|||||||
| POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag |
|
| POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag |
|
||||||
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
|
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
|
||||||
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
|
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
|
||||||
|
| GET | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 |
|
||||||
|
| GET | `/engagement/audiences` | every declared audience a rule may be pointed at, with its params and ceiling. The `resolve` function is **never served** — an audience answers with user ids on the server side only, so a module still cannot enumerate addresses. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
|
||||||
|
| GET | `/engagement/channels` | every registered delivery channel a rule may send on, with its `defaultMode`. From the delivery-channel registry, so the rule editor offers exactly the set the save path checks and a module-registered channel appears with no client release. `adminOnly` |
|
||||||
|
| GET | `/engagement/audience-preview` | `?audience=` **or** `?audienceSegmentId=`, plus an optional `?triggerId=`. Runs the **same resolver the engine runs** and answers `{count, capped, ceiling, dormant, reason, permitted}` — a **count only**, never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true at the 5000-row audience bound, where the count is a floor and not a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries; `permitted` is whether the trigger's G24 ceiling allows the reach just counted. `adminOnly` |
|
||||||
|
| GET · POST | `/engagement/rules` | list every rule annotated with **dormancy** (and why), or create one. A new rule must name a currently-registered trigger, arrives `enabled: 0` (§7.1 Q3) and has its audience checked against that trigger's ceiling. `400` carries every problem in `errors[]`, not just the first. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.5 |
|
||||||
|
| GET · PUT · DELETE | `/engagement/rules/:id` | read, replace or delete one rule. The **trigger is not updatable** — a rule's cooldowns, its pending outbox rows and its send-log history are all about one trigger id, and re-pointing it would silently re-attribute all three. An existing rule may keep naming an unregistered trigger, so a dormant rule stays editable. `DELETE` cascades its cooldowns and pending outbox rows; `engagement_sends` carries **no foreign key**, so the send log outlives the rule |
|
||||||
|
| PATCH | `/engagement/rules/:id/enabled` | flip that column and no other, **without re-validating the rule**. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating `PUT` refuses to save. Turning one on is safe unvalidated because the engine re-checks the ceiling at send time |
|
||||||
|
| GET · POST | `/engagement/segments` | list every saved audience segment annotated with dormancy (and which audience ids are missing), or save a new one. The stored `ceiling` is **derived** as the narrowest in the expression and is never taken from the caller; `not` is legal only as a child of `and`; two incomparable ceilings have no meet and the composition is refused rather than guessed. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
|
||||||
|
| PUT · DELETE | `/engagement/segments/:id` | update (re-deriving the ceiling) or delete. **`409` while any rule still points at it**, with the count in the message. No foreign key does this on purpose: `CASCADE` would delete an operator's rules and `SET NULL` would silently fall each rule back to its plain `audience` column, which reaches a *different set of people* |
|
||||||
|
| GET | `/engagement/templates` | every message template, each annotated with three separately-meaningful warnings: `dormant` (pinned to a trigger no installed module declares, so its variables cannot be checked and nothing will send it), `triggerBehind` (the module is installed but its declaration has moved on past the version this template was authored against) and `seedBehind` (a newer shipped default exists and was **not** applied, because a person had edited this row). `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.6.2 |
|
||||||
|
| GET | `/engagement/templates/:id` | one template plus `variables` — the palette the editor offers, resolved from the trigger declaration or, for a template tied to no trigger, from the shipped seed, merged with the ambient variables every template may use. Served with the row so the editor never guesses what is legal |
|
||||||
|
| PUT | `/engagement/templates/:id` | edit any template, **including a shipped default, in place**: the save sets `customized = 1`, which is what stops the next seed bump from taking the edit back. `key` and `channel` are **immutable and the attempt is refused rather than ignored** — `mailer` renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of the route: a token (or an `email.itemList` naming a bare variable) referencing something the trigger does not declare is refused **with the variable named**, and a `published` template whose plain-text part renders empty is refused — checked by *rendering* with the declared examples, because whether a text part exists depends on what each block's `toText` does with these props |
|
||||||
|
| POST | `/engagement/templates/:id/duplicate` | the **only** way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a `draft`, is never `protected`, and **inherits the source's `seed_key`** — that is what carries its variable palette, not bookkeeping: a seedless, triggerless copy would resolve to the ambient variables alone and be refused for the tokens it was copied with. `409` on a taken key |
|
||||||
|
| DELETE | `/engagement/templates/:id` | `409` for a `protected` template — the system breaks without a password-reset body, so those are editable and not deletable — and `409` while any rule's `template_keys` points at the key, **naming the rules**. The same answer a segment in use gets, for the same reason: the alternative is a rule that silently stops producing mail |
|
||||||
|
| POST | `/engagement/templates/:id/preview` | renders the body **in the request**, not the stored row, using each variable's declared `example` — which is why `example` is a required part of a trigger declaration rather than documentation. A `POST` that writes nothing: an editor that could only preview what was already saved would make saving the way to find out whether a change was right. Returns both parts as JSON strings; the client renders the HTML inside `<iframe sandbox="" srcdoc>` with **no `allow-scripts`**. Serving it as a document from this origin would run operator-authored HTML under the site's own CSP with access to its cookies |
|
||||||
|
| POST | `/engagement/templates/:id/test-send` | sends what is on screen, saved or not, through the configured transport, and records the attempt in `engagement_sends` **including when it fails** — the outcome an operator most needs a record of. `trigger_id` is `NOT NULL` and a transactional template has no trigger, so the row is logged under the synthetic **`core.admin.test-send`**, which is deliberately not a registered trigger. It does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user. `409` when mail is unconfigured, `502` when the relay refuses |
|
||||||
|
| GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1–200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment |
|
||||||
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
|
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
|
||||||
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
|
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
|
||||||
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |
|
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |
|
||||||
@@ -1144,13 +1594,141 @@ still at [`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md).
|
|||||||
|
|
||||||
## 7. Email
|
## 7. Email
|
||||||
|
|
||||||
`utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
|
`utils/mailer.js` (nodemailer) sends through a **registered mail transport**, configured in
|
||||||
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
|
Admin → Settings → Email — never env, and never a compiled-in provider. Gmail OAuth2 and its consent
|
||||||
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
|
flow were **removed** in engagement Phase 1 ([`ENGAGEMENT.md`](ENGAGEMENT.md) §1.2a); SMTP is the
|
||||||
singleton (never returned over the API). The OAuth client id/secret are reused from the `google`
|
baseline and the only transport core ships.
|
||||||
auth-providers row. Recipient is the `contact_email` site setting. If email is unconfigured/disabled,
|
|
||||||
`POST /public/contact` returns `{fallback:"mailto", email}` so the client renders a `mailto:` link
|
**Transport, not provider.** `server/src/engagement/transports/` holds the registry and its one
|
||||||
instead. Errors never leak credentials.
|
registration. A transport declares an id, a label and its own **`credentialFields`**, and that
|
||||||
|
declaration is the single thing the admin form renders, the request sanitizer filters against, and
|
||||||
|
the "is this value secret" answer comes from — so adding a relay is a registration, not four edits
|
||||||
|
across a form, a validator, a column set and a model. `registerDeliveryChannel`, the other half of
|
||||||
|
§3.1, arrives with the engine that consumes it.
|
||||||
|
|
||||||
|
**Configuration** lives in the `email_config` singleton: `transport` (default `smtp`),
|
||||||
|
`sender_email`, `sender_name`, `reply_to`, and `credential_enc` — the transport's whole credential
|
||||||
|
set as ONE AES-256-GCM JSON blob (`utils/secretBox.js`), because the field list belongs to the
|
||||||
|
transport and a column per union member would make each new transport a schema change. The blob is
|
||||||
|
**write-only over the API**: secret fields are never returned, only a per-field `secretsSet` flag,
|
||||||
|
and a blob that will not decrypt reads as *absent* rather than raising — a rotated `SECRET_ENC_KEY`
|
||||||
|
must land an admin on a screen that says "unconfigured", not a 500 that takes the contact form with
|
||||||
|
it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the
|
||||||
|
additive-only discipline.
|
||||||
|
|
||||||
|
**What each sender still owns is its recipient, its headers and its failure contract — not what it
|
||||||
|
says.** Engagement Phase 5a moved every subject and body out of `mailer.js` into `engagement_templates`
|
||||||
|
rows (§4.6.1); the file's five senders call one seam, `engagement/templates.renderByKey`, which falls
|
||||||
|
back to the shipped seed when the row is missing or unusable. Two consequences:
|
||||||
|
|
||||||
|
- **Mail is now `multipart/alternative`.** Nothing here had an HTML part before. The **text part is
|
||||||
|
byte-identical** to what the deleted literals built — pinned by `test/emailTemplates.test.js`, whose
|
||||||
|
expected strings *are* those literals — and the HTML part is new, table-based and inline-styled.
|
||||||
|
- **Subjects now resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
|
||||||
|
`settings.getInstanceName()` — the admin-set `site_title`, falling back to `BRAND_NAME`. On a
|
||||||
|
deployment that never set a site title nothing changes; on one that did, the subject finally says
|
||||||
|
what the site calls itself.
|
||||||
|
|
||||||
|
**No phone-home.** No transport may ship a default host, port, endpoint or sender
|
||||||
|
([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is
|
||||||
|
`unconfigured` and its channel is off — it never falls back to a destination we chose.
|
||||||
|
`npm run check:hosts` is the CI guardrail; it reads code, not prose, so documentation naming a host
|
||||||
|
is fine and a literal in a transport is not.
|
||||||
|
|
||||||
|
**Three supported SMTP postures**, in the order an operator should consider them:
|
||||||
|
|
||||||
|
| Posture | Shape | When |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A relay** (recommended) | Mailgun / SES / Postmark, host + port 587 + API-key-as-password | Anything with real volume. Reputation, bounce handling and DKIM are the relay's problem, not the operator's |
|
||||||
|
| **A mailbox provider over SMTP** | e.g. `smtp.gmail.com` port 587 with an **app password** (not the account password) | A small deployment, and the migration path off the removed OAuth2 flow. Subject to the provider's own daily send caps |
|
||||||
|
| **A self-hosted MTA** | An unauthenticated relay on port 25 on the same host | An operator who already runs mail. `user` and `password` are left blank; the transport treats a username with no password as incomplete, since that authenticates as nobody |
|
||||||
|
|
||||||
|
`secure` is the field operators get wrong: on for implicit TLS on 465, **off** for 587, which
|
||||||
|
nodemailer upgrades with STARTTLS. 587-with-secure-on hangs rather than erroring cleanly.
|
||||||
|
|
||||||
|
**"Send test" is the verification, and it has to be.** Under the removed consent flow the sending
|
||||||
|
address came back from Google's userinfo and was guaranteed to be a mailbox the credential owned.
|
||||||
|
Operator-typed, it can be refused by the relay — a silent SPF/DMARC deliverability failure, not an
|
||||||
|
error — so `POST /admin/email/test` is the only thing that proves the whole configuration, and its
|
||||||
|
failures name the sender and the likely cause rather than passing a bare `550` through.
|
||||||
|
|
||||||
|
**Failure contracts.** Recipient for the contact form is the `contact_email` site setting. If email
|
||||||
|
is unconfigured or disabled, `POST /public/contact` returns `{fallback:"mailto", email}` so the
|
||||||
|
client renders a `mailto:` link, invites return the accept link for an admin to share by hand,
|
||||||
|
password resets still answer a generic 200, and Team notifications are logged and swallowed. Only
|
||||||
|
the admin test send throws — it is the only one with someone waiting to be told. Errors never leak
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
**The upgrade is silent by design and therefore announced.** An existing deployment backfills to
|
||||||
|
`transport='smtp'` with no credentials, so every sink above politely does nothing and **mail simply
|
||||||
|
stops**. The admin dashboard warns whenever the deprecated Gmail token is present and no replacement
|
||||||
|
credential is; see [`UPGRADE_NOTES.md`](UPGRADE_NOTES.md).
|
||||||
|
|
||||||
|
### Deliverability: suppression, bounces and the verification gate *(engagement phase 9)*
|
||||||
|
|
||||||
|
Engagement Phase 9 ([`ENGAGEMENT.md`](ENGAGEMENT.md) Phase 9). Two mechanisms decide that a person
|
||||||
|
who is *in* a rule's audience does not get the mail, and they are deliberately at different points
|
||||||
|
in the pipeline.
|
||||||
|
|
||||||
|
**`engagement_suppressions` — checked at DELIVERY.** Keyed on `address_hash` (sha256 of the
|
||||||
|
lower-cased address), because a bounce arrives as an address and stays true after the account behind
|
||||||
|
it changed its address or was deleted. An outbox row can sit through a rule's `delay_seconds` grace
|
||||||
|
window and an address can bounce inside it, so the only correct check is the one taken immediately
|
||||||
|
before the transport call — which is also what produces the `status='suppressed'` row in
|
||||||
|
`engagement_sends` with no transport call at all.
|
||||||
|
|
||||||
|
**The verification gate — applied at ENQUEUE.** With the `email_verification_required` setting on
|
||||||
|
(seeded in Phase 1b: `on` for a fresh install, `off` for an upgrade), an unverified address is
|
||||||
|
excluded before an outbox row is written. It hangs off a channel's optional **`eligible(userIds)`**
|
||||||
|
registration rather than living in the engine: being unverified is an *email* fact, and a rule
|
||||||
|
spanning email and in-app must still reach that person's inbox. Only `email` declares one. The
|
||||||
|
excluded count comes back so the admin reach preview reports it instead of quietly promising a
|
||||||
|
number the engine will not deliver.
|
||||||
|
|
||||||
|
**Scope: engagement rules only.** Password resets, invites, verification mails and the contact form
|
||||||
|
still attempt to a suppressed or unverified address. This is the posture `passwordReset.controller.js`
|
||||||
|
already took — user-initiated mail must not be blocked by a background system's opinion, and one
|
||||||
|
reset to a dead mailbox is not a reputation problem, whereas a rule mailing thousands of people
|
||||||
|
weekly is.
|
||||||
|
|
||||||
|
**What may write a `bounce` row is narrower than "the send failed".** `src/engagement/bounceClassify.js`
|
||||||
|
is the only judge, and it is deliberately **not** `mailer.PERMANENT_CODES` — that set answers "is
|
||||||
|
retrying pointless?" and contains `EAUTH` and `554`, so reusing it would mean one stale SMTP password
|
||||||
|
suppressing every address the worker touched, silently. The classifier reads the **RFC 3463 enhanced
|
||||||
|
status** first (`5.1.1`, `5.1.2`, `5.1.3`, `5.1.6`, `5.1.10`, `5.2.1` suppress; `5.3.x`, `5.5.x` and
|
||||||
|
`5.7.x` never do, being about the server or our standing with it), and falls back — only for `550`,
|
||||||
|
`551` and `553`, and only past a veto list — to a phrase match. **Anything it is unsure about is not
|
||||||
|
suppressed:** a false negative costs one retry next month, a false positive costs a person who
|
||||||
|
silently stops hearing from the deployment.
|
||||||
|
|
||||||
|
SMTP has no *asynchronous* bounce or complaint feed — that is where an API-based provider would earn
|
||||||
|
its place — but a single-recipient send refused at `RCPT TO` throws synchronously with the reply
|
||||||
|
code intact, which is the highest-value signal there is and is what this reads. `sendNotification`
|
||||||
|
therefore returns an `smtp: { code, responseCode, response }` triple alongside its classification;
|
||||||
|
`retry` and `detail` cannot answer "was this the recipient's fault", since `550 5.1.1` and
|
||||||
|
`550 5.7.1` are an identical `retry: false`.
|
||||||
|
|
||||||
|
**Statuses.** `engagement_sends.status` gains two real writers: `suppressed` (declined to try) and
|
||||||
|
`bounced` (tried, the mailbox does not exist). `engagement_outbox.status` records `bounced` as
|
||||||
|
`failed` — its ENUM has no such value and, from the queue's point of view, a bounced row is one that
|
||||||
|
finished unsuccessfully. `complained` still has no writer: it needs a provider feedback loop.
|
||||||
|
|
||||||
|
**Routes** (all `adminOnly`, under `/api/v1/admin/engagement`):
|
||||||
|
|
||||||
|
| Route | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /suppressions` | Paged, filterable by `reason` / `channel` / `search`, plus unfiltered `byReason` totals |
|
||||||
|
| `POST /suppressions` | `reason` is forced to `manual` — an admin typing an address is not evidence of a bounce. An address already listed answers 200 with `created: false`, not 409 |
|
||||||
|
| `DELETE /suppressions` | The only way out of the list. The address goes in the **body**, not the path: a path parameter lands in the access log, the browser history and every proxy in front of the deployment |
|
||||||
|
|
||||||
|
**Neither route ever returns `address_hash`**, the same rule `GET /sends` follows: a sha256 of every
|
||||||
|
address on the deployment, handed to a browser, is an offline dictionary attack. What the list
|
||||||
|
returns is `address_masked` — `d***@example.com` — which Phase 9 added to §4.5's DDL because a
|
||||||
|
hash-only table cannot be operated: an operator has to be able to see a whole domain refusing mail
|
||||||
|
and to let back in somebody who fixed their mailbox. The domain survives intact for the first; the
|
||||||
|
local part is destroyed rather than shortened, so the column can never be read back as an address
|
||||||
|
book. The consequence is that **lifting a suppression needs the full address typed in** — the screen
|
||||||
|
genuinely does not have it, which is the privacy design working rather than a rough edge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
4237
website/ENGAGEMENT.md
Normal file
4237
website/ENGAGEMENT.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,13 +26,152 @@ here extends the contract first, in this file, before the module is written agai
|
|||||||
Core exports a single integer-major semver string from `server/src/modules/version.js`:
|
Core exports a single integer-major semver string from `server/src/modules/version.js`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const MODULE_API_VERSION = '1.6.0'
|
const MODULE_API_VERSION = '1.9.0'
|
||||||
```
|
```
|
||||||
|
|
||||||
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
|
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
|
||||||
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
||||||
module chunk evaluates, which is earlier than any network round trip could answer.
|
module chunk evaluates, which is earlier than any network round trip could answer.
|
||||||
|
|
||||||
|
**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
|
||||||
|
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
|
||||||
|
written against 1.8.0 keeps working and simply seeds nothing.
|
||||||
|
|
||||||
|
It exists because 1.7.0 let a module say what an event's payload *is* and gave it no way to say what
|
||||||
|
the message should *read* like. `engagement/templateSeeds.js` and `engagement/coreRules.js` are core
|
||||||
|
files with core arrays in them and there was no registration call beside them, so a module's
|
||||||
|
notification was core's generic `notify.event` body or nothing at all. That is tolerable for one
|
||||||
|
trigger and not for a catalogue: Phase 11 ships twenty-five, sixteen of which have domain prose that
|
||||||
|
core must never contain (§5.2 — and `check:modules` reads identifiers, never prose, so this boundary
|
||||||
|
is honoured deliberately rather than enforced mechanically).
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.registerEngagementSeeds({
|
||||||
|
templates: [{
|
||||||
|
key: 'uo.house.idoc-warning', // MUST be namespaced "<moduleId>."
|
||||||
|
name: 'House — final decay warning',
|
||||||
|
channel: 'email', // 'email' | 'inapp'
|
||||||
|
subject: 'Thy house at {{region}} stands in peril',
|
||||||
|
triggerId: 'uo.house.idoc_warning',
|
||||||
|
triggerVersion: 2,
|
||||||
|
seedVersion: 1,
|
||||||
|
blocks: [ /* the same block objects the template editor writes */ ],
|
||||||
|
}],
|
||||||
|
ruleGroups: [{
|
||||||
|
key: 'triggers-v1', // the one-shot guard's name — see below
|
||||||
|
note: 'shown in the boot log when it inserts',
|
||||||
|
rules: [{
|
||||||
|
trigger_id: 'uo.house.idoc_warning', // MUST be one of this module's own
|
||||||
|
name: 'House decay warning',
|
||||||
|
audience: 'owner',
|
||||||
|
channels: ['email', 'inapp'],
|
||||||
|
template_keys: { email: 'uo.house.idoc-warning', inapp: 'uo.house.idoc-warning-inapp' },
|
||||||
|
cooldown_seconds: 86400,
|
||||||
|
max_sends_per_hour: 200,
|
||||||
|
delay_seconds: 900, // optional
|
||||||
|
cancel_on: ['uo.house.refreshed'], // optional
|
||||||
|
conditions: null, // optional
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callable once per module, and validate-then-commit like every other registration.** A module that
|
||||||
|
got one of thirty templates wrong ships none of them and finds out at boot with the offending key
|
||||||
|
named, rather than at send time with a half-seeded table.
|
||||||
|
|
||||||
|
**The two halves behave differently, and the asymmetry is the contract.**
|
||||||
|
|
||||||
|
- **Templates are re-ensured on every boot.** Each row carries `seed_key`, `seed_version` and
|
||||||
|
`customized`, so re-ensuring is how a better default reaches a deployment *without* stealing an
|
||||||
|
operator's edit (§4.6.1 property 3 of `ENGAGEMENT.md`), and a template added in a later module
|
||||||
|
version reaches every deployment rather than only fresh ones. **Bump `seedVersion` when a body
|
||||||
|
changes; never for a comment.**
|
||||||
|
- **Rules are one-shot, per named GROUP.** Re-ensuring a rule would resurrect one an operator deleted
|
||||||
|
and reset one they enabled, so each group carries its own settings guard
|
||||||
|
(`engagement_module_rules_seeded:<owner>:<group>`). **A rule appended to an existing group therefore
|
||||||
|
reaches fresh installs ONLY** — never a deployment already stamped. A rule that must reach existing
|
||||||
|
deployments takes a **new group key**. The module names its groups, so the module makes that choice;
|
||||||
|
make it knowingly.
|
||||||
|
|
||||||
|
**Three things a module may not do, each of which is a shipped mistake that would only surface as
|
||||||
|
mail somebody received.**
|
||||||
|
|
||||||
|
1. **A seeded rule is always `enabled = 0`.** `enabled` is not a parameter — a value passed for it is
|
||||||
|
ignored rather than refused, because refusing would let a typo take a module offline at boot. This
|
||||||
|
is `ENGAGEMENT.md` Q3's invariant surviving the largest seed set in the workstream.
|
||||||
|
2. **A module may not mark a template `protected`.** That flag means *"the system breaks without this
|
||||||
|
body"*, which is true of a password reset and of nothing a module ships; setting it would take an
|
||||||
|
operator's delete button away.
|
||||||
|
3. **A rule may only name its own module's `trigger_id`**, and its `template_keys` may only name this
|
||||||
|
module's own seeds or **core's** (`notify.event`, `inapp.event`, `notify.digest` — which is
|
||||||
|
§4.6.1 property 1 in force, and the right answer for any trigger whose message is structural).
|
||||||
|
A template `key` must be namespaced `<moduleId>.`, because `engagement_templates.key` is UNIQUE
|
||||||
|
across the table and an unprefixed `notify.event` from a module would collide with core's and win
|
||||||
|
or lose on boot order.
|
||||||
|
|
||||||
|
**When it runs, which is not where the other seeders run.** `server.js` calls `seedDefaults()`
|
||||||
|
**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader —
|
||||||
|
so at the moment core seeds its own templates, no module has registered anything. Module seeds are
|
||||||
|
therefore written from `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and
|
||||||
|
**before** the `onBoot` dispatch. Two consequences worth relying on: a module the operator **disabled**
|
||||||
|
(or one that failed to load) is skipped rather than seeded, and a module that warms a cache in
|
||||||
|
`onBoot` may assume its bodies and rules already exist. Failure is logged and swallowed like every
|
||||||
|
other step there — a body that would not seed costs the shipped default, never the boot.
|
||||||
|
|
||||||
|
**It is not a send path.** Everything on the object is data; nothing on it is a function and nothing
|
||||||
|
on it names a recipient. A module still cannot mail anyone (§1.2): it declares who an event is
|
||||||
|
*about*, and core decides who is told, after the ceiling, the preferences, the suppression list and
|
||||||
|
the verification gate.
|
||||||
|
|
||||||
|
**1.8.0 — a seventh audience ceiling: `admin`** (`website/ENGAGEMENT.md` Phase 11, decision 1). One
|
||||||
|
addition and no removal, so minor; every declaration valid under 1.7.0 is valid now and no stored
|
||||||
|
value changes. `admin` is a **child of `staff`**, so a module may declare `ceiling: 'admin'` on a
|
||||||
|
trigger or an audience and a `staff`-ceilinged trigger accepts an `admin` audience as a narrowing.
|
||||||
|
|
||||||
|
It exists because the narrowest role-shaped value the lattice had was `staff`, which means **admin,
|
||||||
|
editor AND moderator**. Phase 11's operator-facing triggers — a digest of what staff did in game, the
|
||||||
|
economy thresholds, the world-save counts — are admin-audience everywhere they are described, and
|
||||||
|
ceilinging them at `staff` would have let an operator save a rule that mails the staff audit digest to
|
||||||
|
every moderator in it.
|
||||||
|
|
||||||
|
**What a module author has to know beyond the new name.** `admin` is the **only pair in the whole
|
||||||
|
lattice with real containment** — every admin is staff, which is exactly what every other pair of
|
||||||
|
branches lacks — so it is the only place `permits` is true between two values below `authenticated`.
|
||||||
|
`permits('staff', 'admin')` holds; `permits('admin', 'staff')` does not, and neither direction holds
|
||||||
|
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
|
||||||
|
unchanged, and so is every rule about composition narrowing rather than widening.
|
||||||
|
|
||||||
|
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
|
||||||
|
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
|
||||||
|
`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and
|
||||||
|
`api.registerEngagementSeeds({...})` (§2.4) ·
|
||||||
|
`ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3).
|
||||||
|
|
||||||
|
**This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked
|
||||||
|
— *a contract owes a bump only once it has landed on `main`* — was true when they were written and is
|
||||||
|
not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has
|
||||||
|
only ever been on `edge`" are **historical, not current**. Everything added from here takes a version
|
||||||
|
of its own. That is also why the integration kit does not go red until the engagement cutover:
|
||||||
|
`ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha
|
||||||
|
declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover
|
||||||
|
window (`ENGAGEMENT.md` Phase 13).
|
||||||
|
|
||||||
|
**As in 1.6.0, the number states the whole surface and the members arrive by phase.**
|
||||||
|
`ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7);
|
||||||
|
everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled
|
||||||
|
on: a member of a declared version that were simply absent would make the version a lie, and one that
|
||||||
|
silently accepted data into a table that does not exist would be worse than either.
|
||||||
|
|
||||||
|
**One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification
|
||||||
|
stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot
|
||||||
|
attach a payload contract to another module's stream and cannot claim a stream id another module has
|
||||||
|
declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the
|
||||||
|
same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable
|
||||||
|
after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment
|
||||||
|
(`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See
|
||||||
|
`registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision.
|
||||||
|
|
||||||
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
|
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
|
||||||
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
||||||
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
||||||
@@ -228,6 +367,45 @@ module-uo does not need is on the list.
|
|||||||
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
|
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
|
||||||
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
|
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
|
||||||
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
|
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
|
||||||
|
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) |
|
||||||
|
| `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) |
|
||||||
|
|
||||||
|
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with
|
||||||
|
`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
|
||||||
|
(`website/ENGAGEMENT.md` §5.2).
|
||||||
|
|
||||||
|
```js
|
||||||
|
ctx.events.emit('uo.house.idoc_warning', {
|
||||||
|
subject: '0x40001234', // optional — else read from the declared subjectKey
|
||||||
|
data: { house: 'The Silver Anvil', decayStatus: 'Greatly' },
|
||||||
|
ownerUserId: 812, // optional — the module resolves it; core never sees a game account
|
||||||
|
dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters
|
||||||
|
occurredAt: new Date(), // optional, defaults to now
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Six things about it are contract rather than implementation:
|
||||||
|
|
||||||
|
- **A module emits its own triggers and nothing else.** The owner is bound by core from the calling
|
||||||
|
module's id and is never read from the arguments. Without that, `emit` would be a way to fire
|
||||||
|
another module's event with a payload of your choosing, and every rule an operator wrote against
|
||||||
|
that trigger would fire on it.
|
||||||
|
- **The payload is validated against the declaration at EMIT, not at render.** A missing `required`
|
||||||
|
variable or a wrong type is **thrown in development and dropped-and-logged in production** — the
|
||||||
|
posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a
|
||||||
|
game-event handler, and a contract problem of core's must not become the module's control flow.
|
||||||
|
Undeclared keys are dropped rather than rejected; they could never be interpolated anyway.
|
||||||
|
- **It returns `undefined` and never throws in production.** There is nothing a module could
|
||||||
|
correctly do with a delivery failure from inside an event handler, so there is nothing to await.
|
||||||
|
- **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game
|
||||||
|
account is and must not learn; the module maps its own account to a user and passes the result.
|
||||||
|
- **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls
|
||||||
|
back to the variable the declaration's `subjectKey` names.
|
||||||
|
- **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused.
|
||||||
|
|
||||||
|
**`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's
|
||||||
|
inbox directly without going through a rule. It is **present and throws** until the in-app channel
|
||||||
|
lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent.
|
||||||
|
|
||||||
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
|
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
|
||||||
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
|
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
|
||||||
@@ -308,6 +486,9 @@ api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
|||||||
api.registerPostHook({ onSaved, onDeleted })
|
api.registerPostHook({ onSaved, onDeleted })
|
||||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
|
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
|
||||||
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
||||||
|
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
|
||||||
|
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
|
||||||
|
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
|
||||||
api.onBoot(async (ctx) => {})
|
api.onBoot(async (ctx) => {})
|
||||||
api.onShutdown(async () => {})
|
api.onShutdown(async () => {})
|
||||||
```
|
```
|
||||||
@@ -567,6 +748,116 @@ but its own reply. `ok` is core's verdict and sits outside the envelope, so a ha
|
|||||||
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
|
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
|
||||||
switches a module off does not leave a live handler behind it.
|
switches a module off does not leave a live handler behind it.
|
||||||
|
|
||||||
|
**`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload
|
||||||
|
contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves,
|
||||||
|
what a rule is written against, and what a template may interpolate
|
||||||
|
(`website/ENGAGEMENT.md` §4.3).
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.registerEventTriggers([{
|
||||||
|
id: 'uo.house.idoc_warning', // <owner>.-prefixed, one namespace with stream ids
|
||||||
|
label: 'House approaching collapse',
|
||||||
|
description: 'A player house dropped into a late decay stage.',
|
||||||
|
kind: 'event', // 'event' | 'scheduled'; default 'event'
|
||||||
|
subjectKey: 'house', // which variable identifies the cooldown subject
|
||||||
|
audience: 'owner', // the DEFAULT a rule is created with
|
||||||
|
ceiling: 'owner', // the widest a rule may EVER be given
|
||||||
|
version: 1, // bumped on a rename or a type change
|
||||||
|
variables: [
|
||||||
|
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
|
||||||
|
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
|
||||||
|
],
|
||||||
|
}])
|
||||||
|
```
|
||||||
|
|
||||||
|
Six things about it are contract rather than implementation:
|
||||||
|
|
||||||
|
- **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across
|
||||||
|
both facets. A module may declare both for the same id — that is one event with a subscription
|
||||||
|
toggle *and* a payload contract, and it is what core does with its own five — but it may not attach
|
||||||
|
a contract to another owner's stream, and the refusal names the holder and the facet. The seven
|
||||||
|
grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams,
|
||||||
|
because under one namespace they are the same ids.
|
||||||
|
- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a),
|
||||||
|
and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated`
|
||||||
|
would silently widen a staff-only event. The seven values are `everyone`, `authenticated`,
|
||||||
|
`subscribers`, `members`, `staff`, **`admin`** (1.8.0, a child of `staff`) and `owner`, ordered by
|
||||||
|
**containment and not by size** — a `staff` ceiling does NOT permit `owner`, because fewer people is
|
||||||
|
not less exposure. `staff` → `admin` is the single true refinement in the tree and the only pair
|
||||||
|
below `authenticated` that `permits` accepts. A default `audience` wider than, or incomparable with,
|
||||||
|
the ceiling is refused at registration.
|
||||||
|
- **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and
|
||||||
|
test-sending a template possible without a live game event, which is the reason template systems go
|
||||||
|
untested. A variable without one is refused.
|
||||||
|
- **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and
|
||||||
|
no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is
|
||||||
|
validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href.
|
||||||
|
- **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`,
|
||||||
|
which looks like the feature working right up until two subjects share it.
|
||||||
|
- **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type
|
||||||
|
change; a template records what it was authored against and renders with a warning rather than
|
||||||
|
interpolating `undefined`.
|
||||||
|
|
||||||
|
A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a
|
||||||
|
prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by
|
||||||
|
`npm run engagement:manifest` and gated in CI with `--check`.
|
||||||
|
|
||||||
|
**`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its
|
||||||
|
own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the
|
||||||
|
governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it.
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.registerAudiences([{
|
||||||
|
id: 'uo.team.members',
|
||||||
|
label: 'Members of a team',
|
||||||
|
params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only
|
||||||
|
ceiling: 'members',
|
||||||
|
resolve: async (params) => [/* user ids */],
|
||||||
|
}])
|
||||||
|
```
|
||||||
|
|
||||||
|
Four things about it are contract rather than implementation:
|
||||||
|
|
||||||
|
- **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an
|
||||||
|
address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not
|
||||||
|
become the back door that lets it — core maps ids to addresses on its own side, after preferences,
|
||||||
|
suppression and the verification gate.
|
||||||
|
- **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label
|
||||||
|
and a `resolve` it may call. The same boundary `registerNotificationStreams` holds.
|
||||||
|
- **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty
|
||||||
|
set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a
|
||||||
|
*different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A
|
||||||
|
resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it
|
||||||
|
does return are filtered to positive integers before core uses them.
|
||||||
|
- **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still
|
||||||
|
checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the
|
||||||
|
intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all
|
||||||
|
and the save is refused rather than guessed. Composition UI is Phase 4's.
|
||||||
|
|
||||||
|
**Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE
|
||||||
|
and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing
|
||||||
|
predates them.
|
||||||
|
|
||||||
|
**`registerEngagementSeeds({ templates, ruleGroups })`** (1.9.0) ships the module's own message
|
||||||
|
BODIES and its seeded rules (`ENGAGEMENT.md` Phase 11b, decision 7). It is the content behind the two
|
||||||
|
declarations above: they say what an event IS and who it is about, and this says what the mail reads
|
||||||
|
like. Callable once per module. The full shape, the three prohibitions and the boot-path placement
|
||||||
|
are in §1.1 under **1.9.0**; four things are contract rather than implementation and belong here:
|
||||||
|
|
||||||
|
- **Templates re-ensure; rule groups are one-shot.** A body is offered again on every boot under
|
||||||
|
`seed_key` / `seed_version` / `customized`, so improving a default reaches deployments that did not
|
||||||
|
edit it. A rule is offered ONCE per named group, because re-offering would resurrect a rule an
|
||||||
|
operator deleted and reset one they enabled. **A rule appended to an existing group reaches fresh
|
||||||
|
installs only** — that is not a limitation to work around, it is the guarantee; a rule that must
|
||||||
|
reach existing deployments takes a new group key.
|
||||||
|
- **A seeded rule is always disabled.** `enabled` is not a parameter. An operator turns a module's
|
||||||
|
mail on; installing a module never does.
|
||||||
|
- **Core's generic bodies are a first-class answer.** A rule may point `template_keys` at
|
||||||
|
`notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a
|
||||||
|
bespoke body when the message has something to say that the structural projection cannot; a trigger
|
||||||
|
whose message is "this happened, here is the link" should not have one.
|
||||||
|
- **It is not a send path.** Every value on the object is data. Core still decides who is told.
|
||||||
|
|
||||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||||
|
|
||||||
### 2.5 Lifecycle
|
### 2.5 Lifecycle
|
||||||
@@ -1746,6 +2037,33 @@ injected chunk executes and calls `window.__rg.registry` (§3.3).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 6.8 A trigger, a rule and an audience outlive the module that declared them
|
||||||
|
|
||||||
|
The forward-compat note `ENGAGEMENT.md` §7.3 asks this document to carry, and the reason it is here
|
||||||
|
rather than there: it is a rule about **the contract**, not about the engagement system.
|
||||||
|
|
||||||
|
A trigger id is namespaced by its owner and collision-checked at registration, exactly as a
|
||||||
|
notification stream is. What is *not* expressed by that is a rule or a template referring to a
|
||||||
|
trigger whose module has been uninstalled.
|
||||||
|
|
||||||
|
> `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately — no foreign key, no cascade — so
|
||||||
|
> a module can be removed and reinstalled without an operator's rules being destroyed. It is the same
|
||||||
|
> decision `announce_job_legs` took for a leg whose module is gone: *"Leave it alone: failing it would
|
||||||
|
> make the job roll up terminal on the strength of a leg that no longer exists, and reinstalling the
|
||||||
|
> module should resume it."*
|
||||||
|
>
|
||||||
|
> A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI — never as
|
||||||
|
> an error, and never auto-deleted. The same holds for a rule whose *audience* is unregistered: it
|
||||||
|
> resolves to the empty set and shows dormant, which is not the same answer as "resolved to nobody"
|
||||||
|
> and must not be rendered as if it were. The failure this prevents is specific: an id that stops
|
||||||
|
> resolving must never silently become a send to a **different** set of people.
|
||||||
|
|
||||||
|
`engagement_templates.trigger_id` is the same, for the same reason, with one addition: a template
|
||||||
|
records the trigger `version` it was authored against, so a declaration that has since been bumped
|
||||||
|
produces a warning in the admin list rather than silently interpolating `undefined`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Part 7 — What the spike proved
|
## Part 7 — What the spike proved
|
||||||
|
|
||||||
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately
|
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately
|
||||||
|
|||||||
158
website/TEAMS.md
158
website/TEAMS.md
@@ -1595,8 +1595,13 @@ function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient
|
|||||||
would be a refactor away from being used.
|
would be a refactor away from being used.
|
||||||
|
|
||||||
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
|
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
|
||||||
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
|
a `last_digest_at DATETIME NULL`, and it is surfaced in two places:
|
||||||
places:
|
|
||||||
|
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
|
||||||
|
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
|
||||||
|
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
|
||||||
|
> record of what a row meant before the migration. **The two preference columns are unchanged and are
|
||||||
|
> still the authority for Team notifications** — the engine reads them rather than replacing them.
|
||||||
|
|
||||||
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
|
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
|
||||||
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
|
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
|
||||||
@@ -1608,7 +1613,7 @@ places:
|
|||||||
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
|
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
|
||||||
"is this person in it", and the guild page is public.
|
"is this person in it", and the guild page is public.
|
||||||
|
|
||||||
### 6.4 Email — the third sink, already built and unused
|
### 6.4 Email — the third sink
|
||||||
|
|
||||||
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
|
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
|
||||||
running neither currently gets no notification that someone replied to their own thread** — which is
|
running neither currently gets no notification that someone replied to their own thread** — which is
|
||||||
@@ -1618,47 +1623,120 @@ Core already has `utils/mailer.js` and an admin-configured `email_config`. The e
|
|||||||
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
|
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
|
||||||
same event**, not a fourth pipeline.
|
same event**, not a fourth pipeline.
|
||||||
|
|
||||||
- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off.
|
> **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
|
||||||
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
|
> line still describes what a recipient receives; what changed is who decides to send it. The design of
|
||||||
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
|
> record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
|
||||||
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
|
> the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
|
||||||
|
> every other doc that touches a contract it does not own.
|
||||||
|
|
||||||
|
#### What moved, and what did not
|
||||||
|
|
||||||
|
`teamNotify.js` had three sinks. **One moved:**
|
||||||
|
|
||||||
|
| Sink | Where it lives now |
|
||||||
|
| --- | --- |
|
||||||
|
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
|
||||||
|
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
|
||||||
|
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
|
||||||
|
|
||||||
|
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
|
||||||
|
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
|
||||||
|
digest, `notify.event` for the two roster events and for announcements).
|
||||||
|
|
||||||
|
#### The four properties this section always claimed, and where each one lives now
|
||||||
|
|
||||||
|
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
|
||||||
|
off.** All three still hold, and the first is now explicit rather than incidental:
|
||||||
|
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
|
||||||
|
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
|
||||||
|
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
|
||||||
|
the event says who it is about.
|
||||||
|
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2):
|
||||||
|
the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable
|
||||||
|
topic. It carries the thread title, an excerpt and a link; never the full post.
|
||||||
|
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
|
||||||
|
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
|
||||||
|
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
|
||||||
|
silences every channel, and `email_mode` decides email and says nothing about the others. The
|
||||||
|
alternative — intersecting it with the newer per-stream preference — would have silenced every
|
||||||
|
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
|
||||||
|
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
|
||||||
|
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
|
||||||
|
and as of Phase 6 there is a second gate above it, below.
|
||||||
|
|
||||||
|
#### **Team email is OFF until an operator turns it on**
|
||||||
|
|
||||||
|
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
|
||||||
|
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
|
||||||
|
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
|
||||||
|
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
|
||||||
|
off; the release note says it too. Push and the Discord bridge are unaffected.
|
||||||
|
|
||||||
|
#### The digest
|
||||||
|
|
||||||
|
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
|
||||||
|
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
|
||||||
|
pending-items table — and, in Phase 6, over the engine's own outbox:
|
||||||
|
|
||||||
|
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
|
||||||
|
2. a post a moderator hid after it was written is simply not in the query;
|
||||||
|
3. **a user who lost forum access between the post and the send is no longer in the recipient set**, so
|
||||||
|
they are not emailed content they can no longer read.
|
||||||
|
|
||||||
|
`since` is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
|
||||||
|
stamp is written **only on a successful send** — stamping first would quietly eat a day of somebody's
|
||||||
|
notifications every time the mail provider had a bad minute.
|
||||||
|
|
||||||
|
**What Phase 6 changed is the state, not the design.** The stamp moved from
|
||||||
|
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
|
||||||
|
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
|
||||||
|
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
|
||||||
|
properties above. The worker is also gated on an enabled email rule, so switching Team email off
|
||||||
|
switches off both halves of it rather than the instant half only.
|
||||||
|
|
||||||
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
|
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
|
||||||
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
|
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
|
||||||
`team_notification_prefs`.
|
`team_notification_prefs`.
|
||||||
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
|
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
|
||||||
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
|
> would start mailing every member of every Team the moment an operator connects a mail transport.
|
||||||
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
|
> Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
|
||||||
> has.
|
> already has.
|
||||||
- **The digest computes at send time and keeps no queue** (as built). The only state is
|
- **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
|
||||||
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
|
`team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
|
||||||
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
|
rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
|
||||||
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
|
argument survives as the default rather than as a sink the code declines to call.
|
||||||
hid after it was written is simply not in the query; and **a user who lost forum access between the
|
|
||||||
post and the send is no longer in the recipient set**, so they are not emailed content they can no
|
#### One-click unsubscribe
|
||||||
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
|
|
||||||
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
|
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
|
||||||
eat a day of somebody's notifications every time the mail provider had a bad minute.
|
site shows.
|
||||||
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
|
|
||||||
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
|
> **A stateless HMAC, not a token table.** Every property that makes a password-reset token a row is
|
||||||
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
|
> absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice
|
||||||
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
|
> must mean what clicking it once meant. The capability is deliberately the narrowest that does the
|
||||||
writes the preference the site shows.
|
> job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
|
||||||
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
|
> anything back on, and names no other scope. `version` is the only revocation a stateless design can
|
||||||
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
|
> offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
|
||||||
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
|
> needed rather than after.
|
||||||
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
|
>
|
||||||
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
|
> **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
|
||||||
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
|
> narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
|
||||||
> once — and it exists before it is needed rather than after.
|
> email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
|
||||||
>
|
> token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
|
||||||
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
|
> and read as the email channel for that Team, which is a reading of what they always meant.
|
||||||
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
|
>
|
||||||
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
|
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
|
||||||
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
|
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
|
||||||
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
|
> `List-Unsubscribe` *header* carries `POST /api/v1/public/engagement/unsubscribe/:token`, because RFC
|
||||||
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
|
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and does
|
||||||
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
|
> not act** — a mail client's link scanner would otherwise silently unsubscribe people who asked for
|
||||||
> session behind it.
|
> nothing. The endpoint answers `200` whatever the token was: a response that distinguished a valid
|
||||||
|
> token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no
|
||||||
|
> session behind it.
|
||||||
|
>
|
||||||
|
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
|
||||||
|
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
|
||||||
|
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
|
||||||
|
|
||||||
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
|
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
|
||||||
is already being built there.
|
is already being built there.
|
||||||
|
|||||||
244
website/UPGRADE_NOTES.md
Normal file
244
website/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# Upgrade notes — website
|
||||||
|
|
||||||
|
**Operator-facing. Newest first.** One entry per change that requires an operator to *do* something,
|
||||||
|
or that changes behaviour they would otherwise discover by accident.
|
||||||
|
|
||||||
|
Most upgrades need nothing: the schema is replayed on every boot with `IF NOT EXISTS` migrations, and
|
||||||
|
a deployment that pulls a new image and restarts is done. This file exists for the exceptions — and
|
||||||
|
the exceptions worth writing down are almost always the ones that **fail quietly**, because a loud
|
||||||
|
failure announces itself and a quiet one does not.
|
||||||
|
|
||||||
|
**Read the entries between the version you are on and the version you are moving to.** Each names the
|
||||||
|
action, whether it is required, and what happens if you do nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engagement: rules, an in-app inbox, and three things that arrive switched off
|
||||||
|
|
||||||
|
**Required.** The engagement workstream, Phases 1–12. Three separate actions, and the two that matter
|
||||||
|
most are the ones nothing will tell you about.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
The site now has a general notification engine. A **trigger** fires (a forum post, a house going
|
||||||
|
IDOC, a governor being appointed), a **rule** decides who hears about it and through which
|
||||||
|
**channel**, and a **template** decides what it says. All of it is admin-editable under
|
||||||
|
**Admin → Engagement** — Rules, Audiences, Templates, Triggers, Send Log and Suppressions.
|
||||||
|
|
||||||
|
Players get an in-app inbox at **Account → Notifications**, with per-channel switches at **Account →
|
||||||
|
Notification settings**, and the same inbox in the Android app.
|
||||||
|
|
||||||
|
### What you must do
|
||||||
|
|
||||||
|
Three things, in this order. The first is a required upgrade step for anyone running a shard; the
|
||||||
|
second and third are only required if you were relying on the behaviour they change.
|
||||||
|
|
||||||
|
#### 1. Update the shard — the wire protocol is now 5
|
||||||
|
|
||||||
|
**Required if this deployment has a shard connected.** Update in this order:
|
||||||
|
|
||||||
|
1. the website (this image),
|
||||||
|
2. the **UO module to 0.5.0** — **Admin → Modules**, paste the 0.5.0 release install-manifest
|
||||||
|
URL, then **restart the server**: modules are read from disk at boot, so nothing changes until
|
||||||
|
you do. It declares `coreApi ^1.9.0`, and a core older than that will refuse to *load* it (the
|
||||||
|
install itself still succeeds) — the module then shows as failed on that screen, naming the
|
||||||
|
version it needs. That is the check working, but it means step 1 has to be finished first;
|
||||||
|
3. the **shard**, with `installer update` on the shard host. That is what pairs a protocol-5 sidecar
|
||||||
|
with a protocol-5 overlay; the installer refuses to pair a mismatched one by design.
|
||||||
|
|
||||||
|
**The order matters, and step 2 is the one that starts the clock.** The protocol pin lives in the UO
|
||||||
|
module's schema: on the first boot after the module updates, `uo_link_config.protocol` moves to 5 and
|
||||||
|
the site starts sending `X-UOLink-Version: 5`. A shard still on 4 answers `409` from that moment on,
|
||||||
|
so shard data stops. Nothing is lost and nothing is corrupted — the site degrades to showing the
|
||||||
|
shard offline, exactly as it does when the sidecar is down — but it stays that way until the shard is
|
||||||
|
updated. If you cannot update the shard in the same window, update the site and leave the module on
|
||||||
|
its current version until you can.
|
||||||
|
|
||||||
|
Protocol 5 adds a decay schedule and owner account to `house.decay`, fee and owner state to
|
||||||
|
`vendor.listing`, and a new `account.login.result`. Those three fields are what the shard-driven
|
||||||
|
rules are built on; a shard on 4 could not feed them.
|
||||||
|
|
||||||
|
#### 2. Team notification email arrives switched off
|
||||||
|
|
||||||
|
**Required if your Teams rely on email.** Team email used to send automatically. It now runs through
|
||||||
|
the engagement engine like everything else, as four rules — and like every rule on the platform, they
|
||||||
|
are seeded **disabled**. Nothing mails until an operator switches one on.
|
||||||
|
|
||||||
|
Open **Admin → Engagement → Rules** and enable the Team rules you want. A banner on that screen says
|
||||||
|
the same thing while they are all off, and disappears when one is enabled.
|
||||||
|
|
||||||
|
**Only email moved.** The push tickle and the Discord bridge are unchanged and kept working through
|
||||||
|
the upgrade, so the app and your Discord channel behave exactly as before. Per-member Team
|
||||||
|
preferences and unsubscribe links still apply above the rules.
|
||||||
|
|
||||||
|
#### 3. News push stops until you switch it on
|
||||||
|
|
||||||
|
**Required if you publish news and expect it to notify.** Publishing a news post used to send a push
|
||||||
|
tickle directly. That call is now an engagement trigger, seeded — again — as a **disabled** rule. A
|
||||||
|
deployment that upgrades and does nothing else tickles nobody on the next publish.
|
||||||
|
|
||||||
|
Same screen: **Admin → Engagement → Rules**, enable the news rule.
|
||||||
|
|
||||||
|
This is deliberate rather than an oversight. The alternative was to keep the old direct tickle beside
|
||||||
|
the new trigger for one release, and an exception with no owner is one nobody removes.
|
||||||
|
|
||||||
|
### Behaviour changes that need no action
|
||||||
|
|
||||||
|
- **An unsubscribe link now turns off one channel, not everything.** The old link muted a user
|
||||||
|
outright, which silenced push as well as mail. It now switches off exactly the channel the link was
|
||||||
|
in.
|
||||||
|
- **Email and push are opt-in; the in-app inbox is on.** A user who changes nothing receives in-app
|
||||||
|
items and no mail. That matches the Team preference model already in use, and it is why enabling a
|
||||||
|
rule does not by itself produce a mail storm.
|
||||||
|
- **A rule cannot mail more people than its trigger allows.** Every trigger declares an audience
|
||||||
|
ceiling, and a rule is checked against it — so a staff-only event cannot be given a public
|
||||||
|
audience, whatever the rule says.
|
||||||
|
- **Bounces now suppress an address.** A hard bounce or a rejected recipient stops further mail to
|
||||||
|
that address and is listed under **Admin → Engagement → Suppressions**, which is also the only
|
||||||
|
screen that can lift one. An address that has stopped receiving mail is the first thing to check
|
||||||
|
there.
|
||||||
|
|
||||||
|
### If you do nothing
|
||||||
|
|
||||||
|
The site upgrades, the in-app inbox works, and no mail is sent — because every rule is off and email
|
||||||
|
needs both a transport (see the SMTP entry below) and a user who opted in. What you lose silently is
|
||||||
|
**Team email** and **news push**, both of which were sending before the upgrade and stop at it. If
|
||||||
|
you also leave the UO module on its current version, the shard keeps working on protocol 4 and none
|
||||||
|
of the shard-driven rules exist yet.
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
Nothing is dropped and no row is deleted. New tables cover rules, segments, templates, the outbox,
|
||||||
|
the send log, cooldowns, suppressions, per-channel preferences and the in-app inbox.
|
||||||
|
`team_notification_prefs` is kept and still consulted — per-Team granularity did not move.
|
||||||
|
|
||||||
|
**Nothing in this schema expires on its own.** Finished outbox rows, the send log and the suppression
|
||||||
|
list are kept until you remove them; there is no retention sweep. On a busy deployment those tables
|
||||||
|
grow without limit, which is worth knowing before it is worth acting on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email addresses are now unique, and some accounts may lose theirs
|
||||||
|
|
||||||
|
**Required only if the dashboard says so.** Engagement Phase 1b.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
An account's email address is now the destination for account recovery and, in time, for notification
|
||||||
|
mail, so an address may belong to **one account only**. Until now it could repeat — SSO sign-up wrote
|
||||||
|
whatever address the provider returned, without checking — so a database that has been running with
|
||||||
|
SSO enabled may well hold duplicates.
|
||||||
|
|
||||||
|
Users can now also **set and change their own address**, under *Account → Email address*, confirming
|
||||||
|
it by opening a link. Before this there was no way for anyone to change their own address at all.
|
||||||
|
|
||||||
|
### What the upgrade does on first boot
|
||||||
|
|
||||||
|
Nothing you need to trigger, and **no account is deleted**. Where several accounts share an address:
|
||||||
|
|
||||||
|
- the **earliest-created** account keeps it — not the "verified" one, because SSO used to mark an
|
||||||
|
address verified merely for existing, so that flag cannot arbitrate anything;
|
||||||
|
- every later account has its address **cleared** (set to nothing) and is listed in a report;
|
||||||
|
- the dashboard then shows a warning naming how many accounts were affected.
|
||||||
|
|
||||||
|
Case is folded — `Foo@x.com` and `foo@x.com` are the same mailbox — but **accents are not**:
|
||||||
|
`josé@x.com` and `jose@x.com` are correctly treated as two different addresses.
|
||||||
|
|
||||||
|
### What you have to do
|
||||||
|
|
||||||
|
If the warning appears, open **Admin → Users** and read the report. It names each affected account and
|
||||||
|
the address it lost. **Those users are the reason this warning exists:** they can still sign in
|
||||||
|
normally, but they can no longer receive password-reset or notification email until they set a new
|
||||||
|
address themselves, and nothing tells them that. Contact them, and point them at *Account → Email
|
||||||
|
address*.
|
||||||
|
|
||||||
|
Dismissing the warning keeps the report — it is the record of what the upgrade did, and the only place
|
||||||
|
the lost addresses survive.
|
||||||
|
|
||||||
|
### Verification email
|
||||||
|
|
||||||
|
Confirmation links are sent through the same transport as everything else, so **if outbound email is
|
||||||
|
not configured, nobody can confirm an address.** The screen says so honestly rather than claiming a
|
||||||
|
mail was sent. If you have not configured SMTP yet, see the entry below first.
|
||||||
|
|
||||||
|
### The verification setting
|
||||||
|
|
||||||
|
A new setting, *require a confirmed address before sending notification email*, is seeded **off on an
|
||||||
|
existing deployment** and **on for a fresh install**. The asymmetry is deliberate: switching it on
|
||||||
|
retroactively would silently stop mailing every user who had already opted in, on the day you
|
||||||
|
upgraded. Nothing reads it yet — it takes effect when engagement notifications ship — so there is no
|
||||||
|
hurry to change it.
|
||||||
|
|
||||||
|
### If you do nothing
|
||||||
|
|
||||||
|
The de-duplication has already run; it runs on the first boot whether or not you read this. What you
|
||||||
|
lose by ignoring it is the chance to tell the affected users, who will otherwise discover it the next
|
||||||
|
time they try to reset a password.
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
Nothing is dropped and no row is deleted. `users` gains `email_norm` (a generated lowercase copy of
|
||||||
|
`email`, which carries the uniqueness index) and `email_pending`. Two tables are added:
|
||||||
|
`email_verifications` and `email_dedupe_report`. Cleared addresses survive in the report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outbound email: the Gmail connect flow is gone; configure SMTP
|
||||||
|
|
||||||
|
**Required, if this deployment currently sends email.** Engagement Phase 1.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
The "Connect Gmail" consent flow has been **removed**. Email now sends through an ordinary mail
|
||||||
|
transport whose credentials an admin types in — SMTP is the baseline and the only transport shipped.
|
||||||
|
|
||||||
|
Removed with it: `GET /api/v1/admin/email/connect/start`, `GET /api/v1/admin/email/connect/callback`,
|
||||||
|
and email's reuse of the `google` authentication provider's client id and secret. That reuse is worth
|
||||||
|
a sentence of its own, because it was a coupling nobody could see: rotating the Google **SSO** secret
|
||||||
|
silently broke outbound **mail**, with nothing on either screen relating the two. Single sign-on is
|
||||||
|
otherwise unaffected — the `google` provider row exists for SSO in its own right and is untouched.
|
||||||
|
|
||||||
|
### What you must do
|
||||||
|
|
||||||
|
On the first boot after the upgrade, `email_config.transport` backfills to `smtp` with **no
|
||||||
|
credentials**. Until you supply some, **no mail is sent** — and nothing errors, because every sender
|
||||||
|
is written to degrade politely:
|
||||||
|
|
||||||
|
- the contact form falls back to a `mailto:` link,
|
||||||
|
- an invite returns the accept link for you to send by hand,
|
||||||
|
- a password reset still answers its usual generic success,
|
||||||
|
- Team notifications are logged and dropped.
|
||||||
|
|
||||||
|
That is the risk this note exists for: **email stops and nothing complains.** The admin dashboard
|
||||||
|
shows a warning while it is true, and the warning goes away by itself once a credential is saved.
|
||||||
|
|
||||||
|
Go to **Admin → Settings → Email**, choose the posture that fits, then use **Send test** — which is
|
||||||
|
now the real verification of the whole configuration, not a formality.
|
||||||
|
|
||||||
|
| Posture | Settings | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A relay** — recommended | The relay's host, port `587`, *Implicit TLS* **off**, your API key as the password | Mailgun, SES, Postmark and equivalents. Reputation, DKIM and bounce handling are the relay's problem. The right answer for anything with volume |
|
||||||
|
| **Gmail over SMTP** — the shortest migration | `smtp.gmail.com`, port `587`, *Implicit TLS* **off**, your Google address as the username, and an **app password** | This is the direct replacement for the removed connect flow. It needs an [app password](https://support.google.com/accounts/answer/185833) — 2-Step Verification must be on, and your ordinary account password will not work. Google's per-day send caps apply |
|
||||||
|
| **Your own MTA** | Its host, port `25`, *Implicit TLS* **off**, username and password left blank | For an operator already running mail on the same host |
|
||||||
|
|
||||||
|
Two fields cause most failures:
|
||||||
|
|
||||||
|
- **Implicit TLS** is *on* only for port **465**. On port 587 leave it **off** — the connection still
|
||||||
|
upgrades to TLS, via STARTTLS. Port 587 with it on does not error; it hangs.
|
||||||
|
- **Send from** is now typed by you rather than read back from Google, so nothing guarantees the
|
||||||
|
server will accept it. It must be an address the account is allowed to send as, or the mail is
|
||||||
|
refused for SPF/DMARC reasons that look like nothing at all. Send test names this specifically when
|
||||||
|
it happens.
|
||||||
|
|
||||||
|
### If you do nothing
|
||||||
|
|
||||||
|
Mail stays off. Nothing breaks and no data is lost — the site, the admin panel, logins, SSO and the
|
||||||
|
shard integration all behave exactly as before. The dashboard warning stays up until you either
|
||||||
|
configure a transport or press **Clear credentials**, which is how you say "this deployment
|
||||||
|
deliberately sends no mail".
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
Nothing is dropped. `email_config` gains `transport`, `credential_enc` and `reply_to`; the old
|
||||||
|
`provider` and `refresh_token_enc` columns are kept but no longer read. Your stored Gmail refresh
|
||||||
|
token is not used again and is cleared the first time you press **Clear credentials**.
|
||||||
@@ -17,30 +17,6 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/health"
|
"path": "/api/health"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/admin/account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/admin/account/identities"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "DELETE",
|
|
||||||
"path": "/api/v1/admin/account/identities/:provider"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/admin/account/totp/disable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/admin/account/totp/enable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/admin/account/totp/setup"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/activity"
|
"path": "/api/v1/admin/activity"
|
||||||
@@ -89,14 +65,6 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/email/config"
|
"path": "/api/v1/admin/email/config"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/admin/email/connect/callback"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/admin/email/connect/start"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/email/disconnect"
|
"path": "/api/v1/admin/email/disconnect"
|
||||||
@@ -105,6 +73,14 @@
|
|||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/email/test"
|
"path": "/api/v1/admin/email/test"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/engagement/audiences"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/engagement/triggers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/invites"
|
"path": "/api/v1/admin/invites"
|
||||||
@@ -145,6 +121,14 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/moderation/recent"
|
"path": "/api/v1/admin/moderation/recent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/moderation/reports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/moderation/reports/:id/handle"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/moderation/search"
|
"path": "/api/v1/admin/moderation/search"
|
||||||
@@ -293,6 +277,98 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/site-mode"
|
"path": "/api/v1/admin/site-mode"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/:id/archive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/:id/display-name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/:id/forum/moderation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/:id/grants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/:id/hide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/:id/leader-override"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/:id/unhide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/forum/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/forum/uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/integrations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/teams/integrations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/teams/integrations/:teamId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/requests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/requests/:id/decide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/resync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/teams/voice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/teams/voice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/teams/voice/:teamId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/teams/voice/sync"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/uploads"
|
"path": "/api/v1/admin/uploads"
|
||||||
@@ -333,6 +409,14 @@
|
|||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/email-dedupe-report"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/wiki"
|
"path": "/api/v1/admin/wiki"
|
||||||
@@ -389,6 +473,14 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/wiki/tags"
|
"path": "/api/v1/admin/wiki/tags"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/email/verify/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/email/verify/:token"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/auth/invite/:token"
|
"path": "/api/v1/auth/invite/:token"
|
||||||
@@ -417,6 +509,18 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/auth/me/account"
|
"path": "/api/v1/auth/me/account"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/auth/me/account/email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/auth/me/account/email/pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/auth/me/account/email/resend"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/auth/me/account/identities"
|
"path": "/api/v1/auth/me/account/identities"
|
||||||
@@ -477,6 +581,14 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/auth/me/notifications/subscriptions"
|
"path": "/api/v1/auth/me/notifications/subscriptions"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/auth/me/notifications/teams"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/auth/me/notifications/teams"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/auth/me/sessions"
|
"path": "/api/v1/auth/me/sessions"
|
||||||
@@ -557,38 +669,6 @@
|
|||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/auth/sso/totp"
|
"path": "/api/v1/auth/sso/totp"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/player/account"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/api/v1/player/account/identities"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "DELETE",
|
|
||||||
"path": "/api/v1/player/account/identities/:provider"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "PATCH",
|
|
||||||
"path": "/api/v1/player/account/password"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/player/account/totp/disable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/player/account/totp/enable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/api/v1/player/account/totp/setup"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "PATCH",
|
|
||||||
"path": "/api/v1/player/account/username"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/appeals"
|
"path": "/api/v1/player/appeals"
|
||||||
@@ -605,6 +685,66 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/appeals/eligible"
|
"path": "/api/v1/player/appeals/eligible"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/teams"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/teams/:slug/access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/report"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/uploads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/teams/:slug/grants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/grants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/player/teams/:slug/grants/:userId"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/public/contact"
|
"path": "/api/v1/public/contact"
|
||||||
@@ -637,6 +777,34 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/status"
|
"path": "/api/v1/public/status"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/teams"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/teams/:slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/teams/:slug/activity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/teams/:slug/members"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/public/teams/unsubscribe/:token"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/version"
|
"path": "/api/v1/public/version"
|
||||||
@@ -674,6 +842,14 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/internal/bot-config"
|
"path": "/internal/bot-config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/internal/commands"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/internal/commands/dispatch"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user