Compare commits
1 Commits
d8af33c805
...
docs/teams
| Author | SHA1 | Date | |
|---|---|---|---|
| ceab67f285 |
@@ -283,26 +283,11 @@ Guilds expose only one in-game event (a member joining), so the roster is polled
|
|||||||
|
|
||||||
| kind | fields | notes |
|
| kind | fields | notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's leader/alliance/name changed, its member count moved, or its first sight this connection. |
|
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
|
||||||
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
||||||
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
||||||
| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects **carrying rank**) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** |
|
|
||||||
| `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. |
|
|
||||||
|
|
||||||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute.
|
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
|
||||||
|
|
||||||
**A roster member carries rank as well.** `rank` is 0–4 with 4 being Leader, plus `rankCliloc` (the cliloc the game names that rank with) or `rankName` when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job.
|
|
||||||
|
|
||||||
Two things to get right, both of which bite:
|
|
||||||
|
|
||||||
- **Several members can hold rank 4.** `guild.update`'s single `leader` is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat `leader` as one more entry rather than the answer.
|
|
||||||
- **An absent `rank` means "not known" — never 0, and never leadership.** It has one deliberate cause: `PlayerMobile.GuildRank` reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided.
|
|
||||||
|
|
||||||
**On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went.
|
|
||||||
|
|
||||||
`guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep.
|
|
||||||
|
|
||||||
**Rosters can arrive in several frames.** Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with `seq: 0, more: false` and needs no special handling — but if you consume the raw stream, accumulate from `seq` 0 and apply on `more: false`, discarding a partial roster if a frame arrives out of order or the shard reconnects. `GET /guilds` hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
||||||
@@ -311,16 +296,8 @@ Two things to get right, both of which bite:
|
|||||||
"t":1752489280000}
|
"t":1752489280000}
|
||||||
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||||||
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
||||||
{"kind":"guild.roster","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"total":14,"seq":0,"more":false,
|
|
||||||
"members":[{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
{"serial":"0x77","name":"Bran","acct":"bran","player":true}],
|
|
||||||
"t":1752489282000}
|
|
||||||
{"kind":"guild.leave","id":1042,"name":"The Silver Hand","who":"0x77","t":1752489283000}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`acct` is genuinely optional on a member — a character can have no account at all — so do not assume it is present.
|
|
||||||
|
|
||||||
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
||||||
|
|
||||||
#### Town governors (Protocol 2.0)
|
#### Town governors (Protocol 2.0)
|
||||||
@@ -876,17 +853,7 @@ GET /guilds
|
|||||||
"t":1752489280000}, ... ] }
|
"t":1752489280000}, ... ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every guild's latest snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Ordered by name. Survives a sidecar restart.
|
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
|
||||||
|
|
||||||
Each entry is a `guild.update` payload **plus, from Protocol 4, a `roster` key** holding the member list — already reassembled, so the frame-splitting described in §4 never reaches this endpoint:
|
|
||||||
|
|
||||||
```
|
|
||||||
→ { "guilds": [ {"kind":"guild.update","id":1042, ..., "roster":[
|
|
||||||
{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
{"serial":"0x77","name":"Bran","acct":"bran","player":true} ]}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
A guild that has had a `guild.update` but no roster yet has **no `roster` key at all** — deliberately distinct from `"roster": []`, which means the guild is genuinely empty. Do not conflate "not known" with "known to be empty".
|
|
||||||
|
|
||||||
### Governor board (Protocol 2.0)
|
### Governor board (Protocol 2.0)
|
||||||
|
|
||||||
|
|||||||
@@ -236,14 +236,6 @@ These are **outbound** streams (shard → website), the natural extension of `PL
|
|||||||
|
|
||||||
### 10.1 Guilds
|
### 10.1 Guilds
|
||||||
|
|
||||||
> **Built in two halves.** 2.0 shipped the board (`guild.update` / `guild.remove`) plus the live
|
|
||||||
> `guild.join`, and folded membership into the board signature as a serial *sum* — so the site got a
|
|
||||||
> member **count** and no roster, and the `guild.leave` sketched below was never emitted. **Protocol 4
|
|
||||||
> builds the rest of what this section describes**: the member-serial set, its diff, `guild.roster`
|
|
||||||
> and `guild.leave`. See [`v4.md`](v4.md). One correction from doing it — a *sum* is not a safe stand-in
|
|
||||||
> for a set, because one member joining and another leaving between two sweeps offset each other and
|
|
||||||
> the guild reads as unchanged. v4 holds the real set.
|
|
||||||
|
|
||||||
**Hook reality (verified):**
|
**Hook reality (verified):**
|
||||||
|
|
||||||
- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`.
|
- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`.
|
||||||
|
|||||||
269
link/v4.md
269
link/v4.md
@@ -1,269 +0,0 @@
|
|||||||
# Protocol 4 — Guild membership on the wire
|
|
||||||
|
|
||||||
**Status:** Feature-complete on `edge`. All work lands on an `edge` branch in each repo; `edge` → `main` is the v4 cutover.
|
|
||||||
**Date:** 2026-08-17
|
|
||||||
**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), [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Why 4
|
|
||||||
|
|
||||||
Protocol 2 gave the website a guild board: name, abbreviation, leader, alliance, and a member
|
|
||||||
**count**. It could say a guild had 155 members. It could not say who they were, and there was no
|
|
||||||
event for anyone leaving one.
|
|
||||||
|
|
||||||
That gap was known when 2.0 shipped. [`PROTOCOL_2.md` §10.1](PROTOCOL_2.md) sketched exactly this
|
|
||||||
design — hold a member-serial set, diff it each sweep, emit `guild.join`/`guild.leave` — and then
|
|
||||||
2.0 shipped only the half that needed no new state, folding membership into the board signature as a
|
|
||||||
serial *sum*. Protocol 4 builds the rest of what §10.1 described.
|
|
||||||
|
|
||||||
The immediate consumer is a richer public Guilds page, which is worth the bump on its own merit. The
|
|
||||||
reason it is being built **now** is that [`../website/TEAMS.md`](../website/TEAMS.md) needs it:
|
|
||||||
platform Teams are sourced from game guilds, and nothing in Team core can be built against a count.
|
|
||||||
That is a dependency, not a coupling — nothing in this protocol version knows what a Team is.
|
|
||||||
|
|
||||||
**Two kinds, both additive:**
|
|
||||||
|
|
||||||
| Kind | Shape | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `guild.roster` | full member list, possibly split across frames | the membership itself; supersedes whatever was held |
|
|
||||||
| `guild.leave` | one departing member | the real-time counterpart to the existing `guild.join` |
|
|
||||||
|
|
||||||
Nothing existing changed shape. `GET /guilds` grows a `roster` key; a v3 consumer that ignores it
|
|
||||||
keeps working.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The shard side
|
|
||||||
|
|
||||||
`BridgeSocial.cs` holds each guild's member serial **set** instead of folding it into the signature
|
|
||||||
as a sum. Two consequences, both the point of the change:
|
|
||||||
|
|
||||||
- **A set comparison cannot collide.** The old sum could: one member joining and another leaving
|
|
||||||
between two sweeps offset each other, and the guild read as unchanged.
|
|
||||||
- **A set can be differenced.** Departures are the prior set minus the current one — which is what
|
|
||||||
makes a per-member `guild.leave` possible without a core tap, since ServUO raises no event for
|
|
||||||
leaving, disbanding, or a leader change.
|
|
||||||
|
|
||||||
A changed set re-emits `guild.roster`, the whole member list. That is what lets `guild.leave` stay
|
|
||||||
**advisory**: a consumer building a "so-and-so left" feed wants the individual events, but a consumer
|
|
||||||
holding a membership table only needs the roster, so nothing downstream has to replay deltas to
|
|
||||||
remain correct. A dropped `guild.leave` costs latency, never accuracy.
|
|
||||||
|
|
||||||
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving. An unknown
|
|
||||||
roster becoming known is not 155 people leaving at once.
|
|
||||||
|
|
||||||
### 2.1 Frames are capped, and split when they must be
|
|
||||||
|
|
||||||
A roster is the only fat frame this bridge emits. Measured against a real 155-member guild: **10,812
|
|
||||||
bytes, about 69 bytes per member.** The sidecar's `read_line` has no length bound, so an uncapped
|
|
||||||
roster is an unbounded line.
|
|
||||||
|
|
||||||
Members per frame are therefore capped (`Bridge.GuildRosterMembersPerLine`, default 500 ≈ 35 KB), and
|
|
||||||
a guild over the cap is split into frames carrying `seq`, `more` and `total`:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"t":1786988708513,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"total":155,"seq":0,"more":true,"members":[ /* 50 actor objects */ ]}
|
|
||||||
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"total":155,"seq":1,"more":true,"members":[ /* 50 */ ]}
|
|
||||||
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"total":155,"seq":2,"more":true,"members":[ /* 50 */ ]}
|
|
||||||
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"total":155,"seq":3,"more":false,"members":[ /* 5 */ ]}
|
|
||||||
```
|
|
||||||
|
|
||||||
Reading the flags: **`seq` 0 begins a roster and supersedes whatever was held for that guild**;
|
|
||||||
`more: false` ends it. Every realistic guild is inside the cap and emits exactly one frame with
|
|
||||||
`seq: 0, more: false` — the same shape as if chunking did not exist. A guild with no members still
|
|
||||||
emits one frame with an empty array, or a consumer could never learn that a roster it holds has
|
|
||||||
emptied.
|
|
||||||
|
|
||||||
`guild.leave` is a small frame naming the departed serial:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"t":1786988770099,"kind":"guild.leave","id":1,"name":"The Silver Hand","who":"0x1F5"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 The reconnect baseline is spread
|
|
||||||
|
|
||||||
`BridgeLink.OnConnected` clears the diff caches, so after a reconnect **every** guild reads as
|
|
||||||
changed at once. The outbound queue is not the risk — the cap is 10,000 *lines* and a few hundred
|
|
||||||
guilds is a few hundred lines — but building hundreds of fat JSON frames in a single Core-thread tick
|
|
||||||
is exactly the stall this bridge exists to avoid.
|
|
||||||
|
|
||||||
So at most `Bridge.GuildRosterGuildsPerTick` guilds (default 25) emit a roster per sweep. A guild over
|
|
||||||
budget keeps its previously held member set, so it still reads as changed on the next pass; its
|
|
||||||
`guild.update` has already gone, so the board's counts are current either way. While a baseline is
|
|
||||||
draining the sweep re-arms itself after 2 s rather than waiting a full `GuildSweepSeconds`, so
|
|
||||||
catch-up takes seconds instead of one sweep interval per batch.
|
|
||||||
|
|
||||||
### 2.3 What a roster member carries
|
|
||||||
|
|
||||||
Each entry is the standard actor object — `serial`, `name`, `player`, plus `acct` when the mobile has
|
|
||||||
an account and `webId` when that account is linked — **and the member's rank in this guild**:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true,
|
|
||||||
"rank":4,"rankCliloc":1062959}
|
|
||||||
```
|
|
||||||
|
|
||||||
`acct` is **genuinely optional**: a `PlayerMobile` can have no `Account` at all, and the local test
|
|
||||||
world contains such mobiles. Consumers must not assume it is present.
|
|
||||||
|
|
||||||
These identity fields are emitted unconditionally, by design — the sidecar is a forwarder, and
|
|
||||||
deciding who may see them is the website's job. See §4.
|
|
||||||
|
|
||||||
#### Rank
|
|
||||||
|
|
||||||
> **Added 2026-08-17, amending Protocol 4 in place.** The version is **not** bumped: Protocol 4 has
|
|
||||||
> not reached `main`, and a protocol owes a bump only once it has been released. The roster shipped
|
|
||||||
> without rank, and Teams phase 2 then found the consequence — the website could learn leadership
|
|
||||||
> only from the board's single `leader` field, so it could name exactly one leader while a UO guild
|
|
||||||
> routinely has several.
|
|
||||||
|
|
||||||
| Field | Shape | When |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `rank` | integer 0–4, 4 being Leader (`RankDefinition.Ranks`) | whenever the rank is known |
|
|
||||||
| `rankCliloc` | integer — the cliloc the game names the rank with | when the rank's name is a cliloc, i.e. the five stock ranks |
|
|
||||||
| `rankName` | string | when a shard's custom rank definition carries a literal name instead |
|
|
||||||
|
|
||||||
**Rank is on roster members only.** It is a property of a mobile's membership of *this* guild, not of
|
|
||||||
the mobile, and every other actor the bridge writes is a bystander, a killer or a governor, where
|
|
||||||
guild rank is meaningless.
|
|
||||||
|
|
||||||
**Only the raw rank is emitted, never a resolved label.** ServUO names the five stock ranks with
|
|
||||||
clilocs (1062959–1062963) and ships no text for them, so the shard cannot produce "Warlord" without a
|
|
||||||
client-file table it does not have. The website module does have one, and resolving a game term is
|
|
||||||
its job in any case.
|
|
||||||
|
|
||||||
**An absent rank means "not known", and a consumer must not read it as 0 or as leadership.** It has
|
|
||||||
one deliberate cause, which is the trap this amendment found: `PlayerMobile.GuildRank` returns
|
|
||||||
`RankDefinition.Leader` for anyone at **GameMaster or above**, whatever their real rank. That is a
|
|
||||||
gameplay convenience so staff can operate a guild stone, not a claim about who leads the guild — and
|
|
||||||
the true value is in a private field with no accessor. So the bridge writes **no rank at all** for a
|
|
||||||
staff account rather than publishing a leadership claim it knows to be false. A staff member who
|
|
||||||
genuinely leads their guild therefore appears unranked, which is a visible gap rather than a lie on a
|
|
||||||
public roster.
|
|
||||||
|
|
||||||
**The sidecar is unaffected.** It treats roster members as opaque values and never reads a field
|
|
||||||
inside one, so a new member field needs no sidecar change and no store migration — which is the
|
|
||||||
forwarder design (§3) doing its job.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The sidecar side
|
|
||||||
|
|
||||||
`guild.roster` writes a **`members` column on the `guilds` board**, not a field inside the existing
|
|
||||||
`json` column. That column holds the verbatim `guild.update` line, and a roster written into it would
|
|
||||||
clobber the snapshot — name, abbreviation, leader, online count — that `guild.update` owns. Two
|
|
||||||
writers across two columns of one row keeps both as plain upserts: neither reads the other's value
|
|
||||||
first, so there is no read-modify-write and no ordering requirement between the two kinds. Either can
|
|
||||||
arrive first.
|
|
||||||
|
|
||||||
`GET /guilds` folds the roster back in as `roster` at read time. A guild that has had a
|
|
||||||
`guild.update` but no `guild.roster` yet simply has **no `roster` key** — the honest representation
|
|
||||||
of "not known", and deliberately distinct from a guild whose roster is genuinely empty.
|
|
||||||
|
|
||||||
`guild.leave` gets **no board projection at all**. The sidecar persists and broadcasts it like any
|
|
||||||
event and leaves the board alone; the roster self-corrects on the next `guild.roster`, which the
|
|
||||||
shard re-emits whenever the member set changes. Keeping the delta out of the projection is what keeps
|
|
||||||
the sidecar a forwarder rather than a thing that maintains state.
|
|
||||||
|
|
||||||
### 3.1 Split rosters are reassembled in memory
|
|
||||||
|
|
||||||
A roster split across frames is reassembled **before** it is stored, and written once on the frame
|
|
||||||
that closes it. Appending to the column per frame was rejected twice over: it would make the write a
|
|
||||||
read-modify-write — the exact thing the two-column split exists to avoid — and it would publish a
|
|
||||||
torn roster, since a reader hitting `GET /guilds` between frames would see a partial member list
|
|
||||||
presented as the whole truth.
|
|
||||||
|
|
||||||
This is transport-level reassembly, the same category of work as turning bytes into a line, and it
|
|
||||||
holds nothing once a roster is complete. The ordinary single-frame case never enters the buffer at
|
|
||||||
all. Around it: a fresh `seq: 0` supersedes an abandoned partial, an out-of-order frame discards the
|
|
||||||
partial rather than storing one with an undetectable hole, a continuation with no start is ignored, a
|
|
||||||
`server.hello` drops every partial (a reconnected shard restarts each roster at 0), and accumulation
|
|
||||||
is bounded so a shard that never sends a closing frame cannot grow the buffer without limit.
|
|
||||||
|
|
||||||
### 3.2 This is the first bump that needed a store migration
|
|
||||||
|
|
||||||
`store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but **cannot add a
|
|
||||||
column to a table that already exists**. Every schema change up to and including Protocol 3.0 added
|
|
||||||
whole tables, so this never mattered and `ALTER TABLE` appeared nowhere in the repo's history.
|
|
||||||
`guilds.members` is the first column added to an existing table: without a mechanism it would simply
|
|
||||||
never reach an installed sidecar, and every roster write would fail.
|
|
||||||
|
|
||||||
The counter is SQLite's own **`PRAGMA user_version`** — an integer in the database header, so it
|
|
||||||
costs no table and cannot drift from the file it describes. Each step runs in a transaction together
|
|
||||||
with the bump recording it, so a step lands completely or not at all and an interrupted run resumes
|
|
||||||
in the right place.
|
|
||||||
|
|
||||||
- **A failure aborts startup**, which was already the behaviour. A half-migrated store answers the
|
|
||||||
website with confusing partial data, which is worse than being plainly absent — and the shard dials
|
|
||||||
*out*, so a sidecar that refuses to start never stalls the game.
|
|
||||||
- **A database written by a newer sidecar warns and continues.** Every step is additive, so a newer
|
|
||||||
schema has only columns an older reader ignores; refusing to start would turn rolling the binary
|
|
||||||
back — a recovery path — into a dead end.
|
|
||||||
|
|
||||||
`sqlx::migrate!` was considered and rejected: it checksums each migration file, so editing an
|
|
||||||
already-released migration hard-fails startup, which is a poor trade for a schema of eleven
|
|
||||||
JSON-blob tables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Visibility
|
|
||||||
|
|
||||||
Both kinds map to the existing **`guilds`** feature in the website's visibility framework
|
|
||||||
([`v3.md` §3](v3.md)). That mapping is required, not cosmetic: rule 2 fails an *unmapped* kind closed
|
|
||||||
to admin-only, which would have quietly kept rosters off the public Guilds page forever.
|
|
||||||
|
|
||||||
Mapping them is safe because of how the framework strips fields. A roster is the first frame carrying
|
|
||||||
locked fields inside an **array** of actors rather than a single nested actor — but the projection
|
|
||||||
walker already recurses into arrays and matches `acct`/`webId` **by suffix, on meaning rather than
|
|
||||||
spelling**. So a member's account name is stripped below `admin` by exactly the rule that already
|
|
||||||
strips `guild.leader.acct`.
|
|
||||||
|
|
||||||
The website stores `acct`/`web_id` per member, because that is what lets a linked member be matched to
|
|
||||||
a site user at all. It never projects them below `admin`. The difference this rule makes is a public
|
|
||||||
page listing character names versus one publishing 150 account names, so it carries a test of its own.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Cross-repo obligations
|
|
||||||
|
|
||||||
| Repo | Change |
|
|
||||||
|---|---|
|
|
||||||
| `servuo-plugins` | member-set diff, `guild.roster` + `guild.leave`, `BridgeJson.Actors`, two `Bridge.cfg` keys, **`overlay.toml` protocol → 4** |
|
|
||||||
| `link` | `PROTOCOL_VERSION` → 4, `members` column + `user_version` migration, roster reassembly, `GET /guilds` projection |
|
|
||||||
| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries |
|
|
||||||
| `installer` | `backup.rs`'s stated reason for skipping the sidecar DB (§3.2 falsifies it) — docs only |
|
|
||||||
| `docs` | this file, `PROTOCOL_2.md` §10.1, `INTEGRATION.md` |
|
|
||||||
|
|
||||||
**`overlay.toml` must move in the same PR as the emitters.** CI folds it into the release manifest and
|
|
||||||
the installer refuses to pair an overlay and a sidecar whose protocol numbers disagree, so a bump
|
|
||||||
landing separately would silently fail to compose into a bundle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Verification
|
|
||||||
|
|
||||||
`servuo-plugins` has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not
|
|
||||||
evidence and neither is a clean boot. What was actually run:
|
|
||||||
|
|
||||||
1. **A throwaway one-guild spike first** (TEAMS.md Phase 0), against the real Rust sidecar rather than
|
|
||||||
a stub, to retire the unknowns before committing to a four-repo bump. It proved the two-column
|
|
||||||
board shape, measured the line, and surfaced §3.2 — the missing migration mechanism — which no
|
|
||||||
amount of reading would have.
|
|
||||||
2. **A live end-to-end run**: 155 members seeded from real `PlayerMobile`s on the local ServUO tree,
|
|
||||||
the cap forced down to 50 so the split path actually fired, producing 50/50/50/5 across four
|
|
||||||
frames; two members then removed on a timer, producing exactly two `guild.leave` frames with the
|
|
||||||
correct serials and a re-emitted roster at `total: 153`.
|
|
||||||
3. **A restart test with the shard stopped first**, so its reconnect could not re-emit and fake
|
|
||||||
persistence. The board's row came back byte-identical.
|
|
||||||
|
|
||||||
Step 2 is what caught the reassembly bug in §3.1: every unit test passed through it, because they all
|
|
||||||
exercised a single-frame roster. The case does not arise until a guild exceeds the cap.
|
|
||||||
|
|
||||||
Still outstanding for the cutover: the five-rung shard visibility walk against a live shard, confirming
|
|
||||||
`acct`/`webId` never reach a caller below their rung.
|
|
||||||
@@ -525,143 +525,6 @@ have been found earlier, because until then no caller had ever passed a non-null
|
|||||||
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
|
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
|
||||||
[`MODULE_API.md`](MODULE_API.md) Part 4.
|
[`MODULE_API.md`](MODULE_API.md) Part 4.
|
||||||
|
|
||||||
### The eleven Team tables — core's, populated by a module (Teams phases 2–5)
|
|
||||||
|
|
||||||
*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its
|
|
||||||
first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its
|
|
||||||
`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it
|
|
||||||
rather than a table of its own.*
|
|
||||||
|
|
||||||
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
|
|
||||||
in them, through the team provider; core stores that answer, gates it and displays it. Every table
|
|
||||||
here is core-internal — a module must never read or write one, even though a module is what fills
|
|
||||||
them — and none carries a `<moduleId>_` prefix, correctly: that rule binds modules, and these are
|
|
||||||
core's.
|
|
||||||
|
|
||||||
| Table | What it holds |
|
|
||||||
|---|---|
|
|
||||||
| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it |
|
|
||||||
| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive |
|
|
||||||
| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine |
|
|
||||||
| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection |
|
|
||||||
| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in phase 2 so the access resolver is written once; the grant flow is phase 4's |
|
|
||||||
| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings |
|
|
||||||
| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core |
|
|
||||||
|
|
||||||
| `team_forum_threads` | forum threads (phase 4). The FULL schema lands with announcements, including the `type`, `pinned` and `locked` columns only discussion uses — phase 5 opens paths rather than migrating data |
|
|
||||||
| `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `<img>` |
|
|
||||||
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
|
|
||||||
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
|
|
||||||
| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer |
|
|
||||||
| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it |
|
|
||||||
| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error |
|
|
||||||
| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer |
|
|
||||||
|
|
||||||
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,
|
|
||||||
`mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere
|
|
||||||
let a *member* say "this is a problem". That was survivable while every piece of content on the site
|
|
||||||
came from staff, and stops being the moment a Team forum lets players write to each other. Four
|
|
||||||
properties are worth carrying:
|
|
||||||
|
|
||||||
- **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a
|
|
||||||
leader-visible queue would route a complaint *about* a leader back to that leader. There is one
|
|
||||||
queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have
|
|
||||||
one place to work — and no leader-facing counterpart anywhere
|
|
||||||
([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18).
|
|
||||||
- **A report is not a moderation action.** Filing one changes nothing about the content; it opens a
|
|
||||||
queue item. That keeps it clear of `team_forum_moderation`, which records things that actually
|
|
||||||
happened, and stops "report" becoming a way for any participant to hide anything.
|
|
||||||
- **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker`
|
|
||||||
that is `1` while open and `NULL` once closed — the same encoding as
|
|
||||||
`team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A
|
|
||||||
closed report frees the slot, so a member whose first report was dismissed may raise the same target
|
|
||||||
again if the behaviour recurs.
|
|
||||||
- **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and
|
|
||||||
declining to act is not is one where the cheapest way to make a report vanish leaves no trace.
|
|
||||||
|
|
||||||
**`teams_forum_edit_window_minutes`** (0–1440, default 15) bounds how long an author may edit their own
|
|
||||||
post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each
|
|
||||||
post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write
|
|
||||||
re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement,
|
|
||||||
and the split exists because a time-bounded permission must not take its clock from the party it
|
|
||||||
bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the
|
|
||||||
admin screen, and the client that needs the decision already has it per post.
|
|
||||||
|
|
||||||
**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off
|
|
||||||
means every forum route answers **404** — not 403, which would advertise a feature the operator
|
|
||||||
deliberately turned off — while threads, posts, grants and notification preferences are all untouched.
|
|
||||||
Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled
|
|
||||||
guard ([`MODULE_API.md`](MODULE_API.md) §4.5).
|
|
||||||
|
|
||||||
**The author never writes an `<img>` tag, and that is what makes the image policy enforceable.** The
|
|
||||||
shared sanitiser (`utils/sanitizeHtml.js`) allows `<img>` from any host — it is tuned for the admin
|
|
||||||
editor, where the author is trusted — so the forum derives its own profile in which `img` is never
|
|
||||||
allowed in any mode. An author writes a URL; core's renderer decides at READ time whether it becomes a
|
|
||||||
picture, under `teams_forum_images` (`disabled` | `remote` | `uploads`). Three properties follow: the
|
|
||||||
policy cannot be evaded, since the only code that can emit an `<img>` is core's; flipping it back to
|
|
||||||
`disabled` un-renders every image on every existing post with **no data migration**; and there is no
|
|
||||||
author-supplied `srcset`, `onerror` or `style` to smuggle anything through. `https:` only, on an
|
|
||||||
extension allowlist, with `referrerpolicy="no-referrer"` and `loading="lazy"` — and **the server never
|
|
||||||
fetches a user-supplied URL**, which would be an SSRF vector; the browser does.
|
|
||||||
|
|
||||||
**`uploads` mode assumes a hostile uploader**, which the admin upload path never had to. Beyond that
|
|
||||||
path's 8 MB cap, mimetype allowlist and random filename it adds: magic-byte sniffing (a client's
|
|
||||||
`Content-Type` is a claim, not a fact), a rolling per-account byte quota, an attribution row per file,
|
|
||||||
and a nightly sweep that removes soft-deleted files past retention plus never-referenced orphans. The
|
|
||||||
sweep runs regardless of the current mode — an operator who turns uploads off still has the files.
|
|
||||||
|
|
||||||
**Selecting `uploads` requires a recorded acknowledgement.** `PUT teams_forum_images = 'uploads'` is
|
|
||||||
rejected **400** unless the same request carries `acknowledge: <version>`; the admin checkbox is how
|
|
||||||
the gate is presented, never the gate. The accepted TEXT VERSION is stored in
|
|
||||||
`teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log`
|
|
||||||
row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a
|
|
||||||
persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is
|
|
||||||
given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the
|
|
||||||
acknowledgement is not.
|
|
||||||
|
|
||||||
**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth
|
|
||||||
failure, so retention ships with the feed rather than after someone notices: a nightly worker applies
|
|
||||||
an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap
|
|
||||||
(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy
|
|
||||||
guild write a million rows inside the window, and a cap keeps a dead Team's feed forever.
|
|
||||||
|
|
||||||
`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence
|
|
||||||
trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core
|
|
||||||
deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing
|
|
||||||
a 155-member guild is one Team arriving, not 155 people joining.
|
|
||||||
|
|
||||||
**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`,
|
|
||||||
`external_id`, `name`) taken together: a known id under a new name archives the old row
|
|
||||||
(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the
|
|
||||||
old Team keeps its activity, its grants and its forum as a read-only record and its old slug still
|
|
||||||
resolves. Whether two names are "really" the same guild is the module's judgement, expressed in
|
|
||||||
whether it reuses the external id.
|
|
||||||
|
|
||||||
**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has
|
|
||||||
no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams`
|
|
||||||
are NULL for archived rows, so any number of them may share an `external_id`.
|
|
||||||
|
|
||||||
**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is
|
|
||||||
`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the
|
|
||||||
generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base
|
|
||||||
column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would
|
|
||||||
delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The
|
|
||||||
semantics are identical: at most one active grant per (team, user), unlimited revoked rows.
|
|
||||||
|
|
||||||
**Account deletion is settled per column, not inherited from the defaults.** Content and audit
|
|
||||||
survive; preferences and links do not. `team_members.user_id` and every actor column on the grant
|
|
||||||
ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record
|
|
||||||
stays readable after the account is gone. Only `team_id` cascades.
|
|
||||||
|
|
||||||
**Two columns exist that the design of record did not contemplate**, both on `teams` and both
|
|
||||||
serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per
|
|
||||||
*module* and a single Team's roster can be left untouched while the others sync — without a per-Team
|
|
||||||
stamp that Team's page would report the module's last success as its own; and `members_empty_since`,
|
|
||||||
the per-Team twin of `pending_empty_since`.
|
|
||||||
|
|
||||||
Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is
|
|
||||||
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. API contract
|
## 4. API contract
|
||||||
@@ -676,7 +539,7 @@ are authoritative, and they answer different questions:
|
|||||||
|
|
||||||
| Artifact | Source of truth for | Generated by |
|
| Artifact | Source of truth for | Generated by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||||
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||||
|
|
||||||
Both are **core's**. An installed module's routes are in neither: they are in that module's own
|
Both are **core's**. An installed module's routes are in neither: they are in that module's own
|
||||||
@@ -773,7 +636,6 @@ 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/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
|
|
||||||
|
|
||||||
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
||||||
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
||||||
@@ -792,14 +654,6 @@ and relies on it: its `/player/shard/*` handlers are the identical self-scoped o
|
|||||||
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. 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
|
|
||||||
guilds too, and gating this group on the role would 403 them off their own Teams.
|
|
||||||
|
|
||||||
| Method | Path | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public |
|
|
||||||
| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied |
|
|
||||||
|
|
||||||
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
|
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
|
||||||
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
|
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
|
||||||
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
|
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
|
||||||
@@ -918,12 +772,6 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
| 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 SMTP; 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` | 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/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 |
|
|
||||||
| 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 |
|
|
||||||
| — | `/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).
|
||||||
@@ -994,16 +842,6 @@ 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 | `/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 |
|
|
||||||
| 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/:id/archive` · `/teams/:id/hide` | staff archive / hide. **Not gated** — both withdraw a Team from public surfaces rather than publishing anything, and withdrawing has to be possible at once, by whoever is on duty |
|
|
||||||
| POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request |
|
|
||||||
| GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums |
|
|
||||||
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
|
|
||||||
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
|
|
||||||
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
|
||||||
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
|
|
||||||
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
||||||
|
|
||||||
Every admin write logs to `activity_log`.
|
Every admin write logs to `activity_log`.
|
||||||
|
|||||||
@@ -26,49 +26,13 @@ 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.5.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.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
|
|
||||||
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
|
||||||
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
|
|
||||||
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
|
|
||||||
|
|
||||||
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
|
|
||||||
>
|
|
||||||
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
|
|
||||||
> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and
|
|
||||||
> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not
|
|
||||||
> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where
|
|
||||||
> a Team page actually is, so a notification email could name a Team and not link to it. See
|
|
||||||
> `registerTeamProvider` below.
|
|
||||||
>
|
|
||||||
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
|
|
||||||
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
|
|
||||||
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
|
|
||||||
>
|
|
||||||
> **The client slots `team.overview` and `team.member.row` are replaced by the INVERTED direction.**
|
|
||||||
> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a
|
|
||||||
> surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own
|
|
||||||
> the word for one, so the module that owns the vocabulary owns the page. In their place,
|
|
||||||
> `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page for CORE to
|
|
||||||
> fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
|
|
||||||
|
|
||||||
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
|
|
||||||
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
|
|
||||||
module may call any member of this version and get the behaviour documented below.
|
|
||||||
|
|
||||||
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
|
|
||||||
existing one is either the module claiming a mount or core notifying it; the closest precedent is
|
|
||||||
`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes
|
|
||||||
the envelope, the 10-second budget and the refusal semantics contract rather than implementation —
|
|
||||||
they are how a module says "I cannot answer" without core hearing "there is nothing".
|
|
||||||
|
|
||||||
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
|
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
|
||||||
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
|
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
|
||||||
written by hand (§3.4). Found by the acceptance run in
|
written by hand (§3.4). Found by the acceptance run in
|
||||||
@@ -217,51 +181,6 @@ module-uo does not need is on the list.
|
|||||||
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
|
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
|
||||||
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
|
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
|
||||||
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
|
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
|
||||||
| `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.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
|
|
||||||
|
|
||||||
**`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
|
|
||||||
`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the
|
|
||||||
module already holds, in its own store.
|
|
||||||
|
|
||||||
**`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{ externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? }
|
|
||||||
```
|
|
||||||
|
|
||||||
Four things about it are contract rather than implementation:
|
|
||||||
|
|
||||||
- **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000
|
|
||||||
gold" for a game whose vocabulary it does not know, and a core that templated it would have
|
|
||||||
re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are
|
|
||||||
likewise opaque — core filters on them and only the module's `team.overview` slot renders anything
|
|
||||||
richer than the text.
|
|
||||||
- **A Team is named by the module's own `externalId`**, which core maps, and only that module's
|
|
||||||
ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an
|
|
||||||
archived Team is not writable — its feed is a closed record of what happened before the rename.
|
|
||||||
- **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core
|
|
||||||
enforces it on the read path.
|
|
||||||
- **It never throws at the call site and never rejects.** This is called from inside a game-event
|
|
||||||
handler, and a storage problem of core's must not become the module's control flow. A malformed
|
|
||||||
item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a
|
|
||||||
sidecar reconnect backfill safe to replay.
|
|
||||||
|
|
||||||
Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership
|
|
||||||
change visible at once; `reconcile` is a debounced *request* that returns immediately and never
|
|
||||||
rejects. Correctness comes from reconciliation either way, so neither can make a module's own call
|
|
||||||
site slow or turn a background failure into the module's error.
|
|
||||||
|
|
||||||
The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`,
|
|
||||||
`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because
|
|
||||||
leadership is its own authority path: a leadership change has to be expressible without pretending
|
|
||||||
someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry
|
|
||||||
`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not
|
|
||||||
invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one
|
|
||||||
from a delta either, because an archive driven by a message that may simply have been repeated is
|
|
||||||
destruction on no evidence.
|
|
||||||
|
|
||||||
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
|
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
|
||||||
|
|
||||||
@@ -298,8 +217,6 @@ api.registerExtension(slot, router)
|
|||||||
api.registerNotificationStreams(streams)
|
api.registerNotificationStreams(streams)
|
||||||
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
||||||
api.registerPostHook({ onSaved, onDeleted })
|
api.registerPostHook({ onSaved, onDeleted })
|
||||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
|
|
||||||
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
|
||||||
api.onBoot(async (ctx) => {})
|
api.onBoot(async (ctx) => {})
|
||||||
api.onShutdown(async () => {})
|
api.onShutdown(async () => {})
|
||||||
```
|
```
|
||||||
@@ -399,166 +316,6 @@ meant a `dispatch` that must not be retried and a `classify` that means nothing.
|
|||||||
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
|
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
|
||||||
naming a UO file, and the last thing binding core to the module.
|
naming a UO file, and the last thing binding core to the module.
|
||||||
|
|
||||||
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
|
|
||||||
module becomes the authoritative source of Teams for this deployment. Two further members,
|
|
||||||
`projectRoster` and `pageUrlTemplate`, are optional and documented below.
|
|
||||||
|
|
||||||
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
|
|
||||||
one authoritative source by construction, and two modules answering "what Teams exist" would produce
|
|
||||||
two disjoint sets under one table with no rule for merging them. A second registration is a
|
|
||||||
collision, reported against the module that holds it. Three methods are required — a provider
|
|
||||||
that could list Teams but not their members would leave core holding Teams it can never populate,
|
|
||||||
which is not the same as a call that fails. The fourth is optional; see below.
|
|
||||||
|
|
||||||
```js
|
|
||||||
getTeams() // () => Promise<{ ok, complete?, teams }>
|
|
||||||
getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
|
|
||||||
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
|
|
||||||
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
|
|
||||||
// => Promise<{ ok, members }> // members = [memberKey]
|
|
||||||
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
|
|
||||||
// e.g. '/uo/guilds/{externalId}'
|
|
||||||
|
|
||||||
// authoritative
|
|
||||||
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
|
|
||||||
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
|
|
||||||
{ ok: false, reason: 'sidecar unreachable' }
|
|
||||||
```
|
|
||||||
|
|
||||||
A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is
|
|
||||||
resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be
|
|
||||||
core reading a module's table by name.
|
|
||||||
|
|
||||||
**Every method returns an envelope, never a bare array, and this is the load-bearing part of the
|
|
||||||
contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a
|
|
||||||
non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate
|
|
||||||
`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is
|
|
||||||
the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is
|
|
||||||
the one a module returns while its sidecar is still connecting.
|
|
||||||
|
|
||||||
A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and
|
|
||||||
surfaces it. It never empties a roster on an answer it does not trust.
|
|
||||||
|
|
||||||
**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It
|
|
||||||
answers *who is allowed to look at a roster*, on the request path, because the audience model and its
|
|
||||||
configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question,
|
|
||||||
"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to
|
|
||||||
whoever asked. So this one call **fails closed**.
|
|
||||||
|
|
||||||
Core distinguishes two refusals, and a module does not have to do anything to get the right one:
|
|
||||||
|
|
||||||
- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and
|
|
||||||
nothing is being withheld, so core serves the roster whole at its own public shape. This is what
|
|
||||||
makes the member genuinely optional: bare core, and a module with no rungs of its own, both render
|
|
||||||
the page core writes.
|
|
||||||
- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core
|
|
||||||
serves an empty roster and says so in the response (`projected: false`,
|
|
||||||
`projectionUnavailable: true`).
|
|
||||||
|
|
||||||
**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core
|
|
||||||
cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md
|
|
||||||
Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary
|
|
||||||
owns the page. That is settled and right, and it leaves core unable to write the link a notification
|
|
||||||
email needs — an email about a forum reply that cannot take you to the thread is most of the way to
|
|
||||||
useless. So the module that owns the page says where it is.
|
|
||||||
|
|
||||||
```js
|
|
||||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders,
|
|
||||||
pageUrlTemplate: '/uo/guilds/{externalId}' })
|
|
||||||
```
|
|
||||||
|
|
||||||
Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only** —
|
|
||||||
a template naming its own host is refused at registration, since there is no reason for a module to
|
|
||||||
redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the
|
|
||||||
member costs the deployment clickable links in Team notification email and nothing else.
|
|
||||||
|
|
||||||
**Data rather than a callback, deliberately.** A function here would put a module hook on the mail
|
|
||||||
path — one more thing that can hang or throw between a forum reply and the mail about it — to produce
|
|
||||||
a string that never varies.
|
|
||||||
|
|
||||||
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
|
|
||||||
an anonymous caller — and never the `users` row, which would make every column of that table part of
|
|
||||||
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
|
|
||||||
published row looks like and re-normalises whatever comes back through its own public shape, so a
|
|
||||||
module can narrow which rows appear and cannot widen which fields do: the member key and the site
|
|
||||||
account id are withheld from every public roster whatever a module returns.
|
|
||||||
|
|
||||||
`complete: false` means "valid but partial": core applies additions and updates and performs **no**
|
|
||||||
removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony.
|
|
||||||
|
|
||||||
**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly
|
|
||||||
omitted from a roster is indistinguishable, downstream, from that member having left — core would
|
|
||||||
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
|
|
||||||
|
|
||||||
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
|
|
||||||
belong to the module, live since phase 7 (TEAMS.md §7.1).
|
|
||||||
|
|
||||||
```js
|
|
||||||
api.registerSlashCommands([{
|
|
||||||
name: 'guild', // lowercase, 1-32, no dots
|
|
||||||
description: 'Show a guild on this shard', // 1-100 characters
|
|
||||||
options: [ // the restricted schema, below
|
|
||||||
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
|
|
||||||
],
|
|
||||||
access: 'everyone', // 'everyone' | 'linked' | 'staff'
|
|
||||||
async handler({ command, options, actor }) {
|
|
||||||
return { title, text, fields, url, ephemeral, notice } // every field optional
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
```
|
|
||||||
|
|
||||||
**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules`
|
|
||||||
volume and cannot load a line of module code, so it pulls the definitions over an internal API and
|
|
||||||
owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality,
|
|
||||||
follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding
|
|
||||||
a Discord handle, and this split is the reason a second platform could implement the same contract.
|
|
||||||
|
|
||||||
**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler
|
|
||||||
learns about the caller:
|
|
||||||
|
|
||||||
| field | |
|
|
||||||
| --- | --- |
|
|
||||||
| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees |
|
|
||||||
| `platformUserId` | the caller's id on that platform |
|
|
||||||
| `guildId` | the platform community the command was run in, or `null` |
|
|
||||||
| `userId` | the site account, or `null` when the platform identity is not linked |
|
|
||||||
| `role` | that account's role — a module with audience rungs needs more than a boolean |
|
|
||||||
| `isLinked` | whether `userId` resolved |
|
|
||||||
| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means |
|
|
||||||
|
|
||||||
A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban
|
|
||||||
does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an
|
|
||||||
operator-chosen slug.
|
|
||||||
|
|
||||||
**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side
|
|
||||||
permission default from it where the platform can express one; core re-checks it in the dispatcher on
|
|
||||||
every call. `'linked'` has no Discord equivalent at all — there is no "has a website account"
|
|
||||||
predicate — so it is simply not advertised, which is exactly why the client half cannot be the
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
**The option schema is deliberately small: `string | integer | boolean | user`,** each with
|
|
||||||
`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete,
|
|
||||||
attachments, modals or component interactions — those are the features whose semantics do not survive
|
|
||||||
a second platform. A command needing them is a bot-side command, written in the bot.
|
|
||||||
|
|
||||||
**A definition the platform would reject fails at `register()`**, not at the next connection: the bot
|
|
||||||
registers the whole set in one call, so one bad option type would cost every command, the bot's own
|
|
||||||
included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option
|
|
||||||
types, and the ordering rule that a required option may not follow an optional one.
|
|
||||||
|
|
||||||
**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's
|
|
||||||
name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that
|
|
||||||
collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core
|
|
||||||
cannot see.
|
|
||||||
|
|
||||||
**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes
|
|
||||||
a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything
|
|
||||||
but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it.
|
|
||||||
|
|
||||||
**A disabled module's commands stop answering immediately.** Registration has no removal path — a
|
|
||||||
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.
|
|
||||||
|
|
||||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||||
|
|
||||||
### 2.5 Lifecycle
|
### 2.5 Lifecycle
|
||||||
@@ -1042,7 +799,6 @@ The kit is **curated and closed**, not a re-export of `components/`:
|
|||||||
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
|
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
|
||||||
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
|
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
|
||||||
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
|
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
|
||||||
| `Slot` *(1.6.0)* | `modules/Slot.jsx` | renders a place this module declared for core to fill (§3.7a) |
|
|
||||||
|
|
||||||
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
|
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
|
||||||
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing
|
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing
|
||||||
@@ -1279,65 +1035,6 @@ intends to fill permanently, and a slot core *does* intend to fill is a slot tha
|
|||||||
members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
|
members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
|
||||||
declaring is core's, and so is reading back who filled what.
|
declaring is core's, and so is reading back who filled what.
|
||||||
|
|
||||||
### 3.7a Inverted slots — CORE content inside a MODULE's page *(1.6.0)*
|
|
||||||
|
|
||||||
The mirror of §3.7, added for Teams. §3.7 assumes core owns the page and a module contributes to it,
|
|
||||||
which is right for the footer and the admin user detail. This is the other shape, and the case that
|
|
||||||
forced it is worth stating because it will recur:
|
|
||||||
|
|
||||||
> **A core primitive whose vocabulary core does not own.** Teams are core's — core owns the tables,
|
|
||||||
> the reconciler, the access resolver and the activity feed — but core has no word for one. A UO shard
|
|
||||||
> calls them guilds; the next game will call them clans. A core-rendered `/teams` page would publish a
|
|
||||||
> noun core invented, beside the module's own page for the same thing. So the **page** is the
|
|
||||||
> module's, and the parts core cannot hand over — here the activity feed, whose public/members split
|
|
||||||
> only core can resolve — are contributed to it.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// In the module's entry chunk, at registration time:
|
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.header')
|
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.forum')
|
|
||||||
|
|
||||||
// In the module's page, from the UI kit:
|
|
||||||
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
|
|
||||||
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
|
|
||||||
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
|
|
||||||
guild page — core fills them with the Team notification control, the activity feed and the Team forum
|
|
||||||
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
|
|
||||||
decision about where each of its contributions sits, on a page the module owns, and the module does
|
|
||||||
use that freedom: the notification control goes **above** the roster because muting is an action *on*
|
|
||||||
the page, and the other two go below it because they are content *in* it. Separate slots also keep
|
|
||||||
them independent: a deployment with the forum switched off renders the other two unchanged.
|
|
||||||
|
|
||||||
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
|
|
||||||
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner
|
|
||||||
readable at the fill site.
|
|
||||||
|
|
||||||
**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's
|
|
||||||
bundle evaluates before every module chunk (§3.1), so at the moment core would like to fill one of
|
|
||||||
these the slot does not exist. Core registers its intent (`fillModuleSlot`, core-only) and
|
|
||||||
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
|
|
||||||
render.
|
|
||||||
|
|
||||||
**A fill for a slot no installed module declares is a no-op, never an error.** The declaring module is
|
|
||||||
simply not installed, which is the ordinary case on any deployment — the exact mirror of an unfilled
|
|
||||||
slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
|
|
||||||
name is always a typo or a version skew, because core declares before any module can name one.
|
|
||||||
|
|
||||||
**First fill still wins**, so a module that fills its own declared slot keeps it and core's fill is
|
|
||||||
skipped. That is deliberate: the module owns the page.
|
|
||||||
|
|
||||||
**`Slot` is the eighth member of the UI kit** (§3.4) for this. A module could not render one of these
|
|
||||||
otherwise, and reimplementing it would mean a second error boundary with different behaviour — which
|
|
||||||
matters more here than anywhere else in the kit, because the thing being contained is *core's* content
|
|
||||||
failing inside the *module's* page.
|
|
||||||
|
|
||||||
`declareModuleSlot` is on the `registry` object handed to modules. `fillModuleSlot` and
|
|
||||||
`applyCoreFills` are not: filling one of these is core's, exactly as declaring a §3.7 slot is.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part 4 — The loader's obligations
|
## Part 4 — The loader's obligations
|
||||||
|
|||||||
980
website/TEAMS.md
980
website/TEAMS.md
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user