Compare commits

..

1 Commits

Author SHA1 Message Date
12bd24a973 docs(teams): queue the integration kit as phase 11, last before the cutover
The kit is the instruction book for putting a different game on this platform,
written for an audience outside this org. Teams expands the contract that book
teaches against, so the book is the last thing the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).

One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.

Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.

The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.

Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:17:26 -05:00
3 changed files with 18 additions and 143 deletions

View File

@@ -674,7 +674,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

View File

@@ -59,9 +59,11 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
> `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page for CORE to > `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. > 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 **The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
module may call any member of this version and get the behaviour documented below. that will implement it — chosen over leaving it absent so that a module written against the published
version fails at registration with a sentence explaining itself, rather than at whatever moment
someone first exercises the feature. Do not call it yet; do not treat its throw as a bug.
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every **`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 existing one is either the module claiming a mount or core notifying it; the closest precedent is
@@ -299,7 +301,7 @@ 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.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 api.registerSlashCommands([...]) // 1.6.0, throws until phase 7
api.onBoot(async (ctx) => {}) api.onBoot(async (ctx) => {})
api.onShutdown(async () => {}) api.onShutdown(async () => {})
``` ```
@@ -490,74 +492,10 @@ removals. It defaults to `true` when omitted, so the ordinary authoritative case
omitted from a roster is indistinguishable, downstream, from that member having left — core would 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. 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 **`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
belong to the module, live since phase 7 (TEAMS.md §7.1). throws with an error naming the phase that will. Present rather than absent so a module written
against the published version fails at registration with an explanation, instead of at the moment
```js someone first types the command.
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.

View File

@@ -100,14 +100,11 @@ module fills with anything live. Core does not grow an SSE stack for this.
exactly two shared-secret HTTP channels: exactly two shared-secret HTTP channels:
- **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js` - **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js`
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since (`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout,
phase 7 `/internal/refresh-commands`), 4s timeout, never throws, always returns never throws, always returns `{ ok, status, data, error }`.
`{ ok, status, data, error }`.
- **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished* - **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished*
internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot
restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and restart self-heals.
`/internal/commands/dispatch` on that same listener — it derives the base from `SITE_INTERNAL_URL`'s
origin rather than taking a second variable naming the same host.
Slash commands are registered from a static array (`bot/src/discord/commands/index.js`) and pushed Slash commands are registered from a static array (`bot/src/discord/commands/index.js`) and pushed
with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set
@@ -1672,42 +1669,6 @@ is already being built there.
### 7.1 Slash-command registration ### 7.1 Slash-command registration
> **Amended 2026-08-18 (phase 7), as built.** Five changes, four of them forced by what the tree
> already looked like.
>
> **The example command is `/guild`, registered by module-uo, and core registers none.** §7.1 wrote
> `/team` as a core command against core's own Team rows. Phase 3 settled that **Teams is a contract
> primitive with no core surface** — core does not own the word for a Team, which is why four core
> Team pages were deleted — and a core `/team` publishes that same invented noun into a channel. The
> module owns the vocabulary, so the module owns the command. Core ships the dispatcher, the actor
> resolver and the transport, and zero commands.
>
> **The deep link comes from `pageUrlTemplate`, because `/teams/:slug` does not exist.** The snippet
> below still says `${siteBaseUrl}/teams/${slug}`; there is no such page. A handler builds its own
> link — module-uo's is `/uo/guilds/{externalId}` — which is the same hole phase 6 found in the mail
> path and closed with the ninth contract member.
>
> **The re-register nudge is its own endpoint, `POST /internal/refresh-commands` on the bot**, not a
> ride on `/internal/config`. That body carries the DECRYPTED bot token: telling the bot that a
> module changed should not require reading a secret out of the database to say it.
>
> **`actor` carries `role` as well as `isStaff`.** The two answer different questions and a boolean
> loses one — `isStaff` is core's gate for `access: 'staff'`, `role` is what a module with its own
> audience rungs needs to place the caller on them. It is the pair `projectRoster`'s viewer already
> carried (§3.3), not a new class of disclosure.
>
> **Deregistration needed a second half this section did not consider.** "A module that is gone is
> simply absent from the next pull" holds across the restart an uninstall asks for. It does not hold
> for the runtime toggle: the registries have no removal path, so a module an operator disables would
> keep a live handler behind a command Discord still advertises. Liveness is therefore asked at both
> the pull and the dispatch, and a disabled owner's command answers `unknown`.
>
> The envelope also gained **`notice`** — a private aside delivered beside a public answer, which is
> how §9 answer 5's "public projection plus an ephemeral prompt to link" is actually expressible: one
> reply cannot be both public and ephemeral, and that it becomes a follow-up is the platform's
> decision, not the handler's.
**Ownership, decided:** the registrant owns the **definition and the handler**; the handler runs **in **Ownership, decided:** the registrant owns the **definition and the handler**; the handler runs **in
the website process** and returns a **response envelope**; the **bot owns every Discord-specific the website process** and returns a **response envelope**; the **bot owns every Discord-specific
concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is
@@ -2524,37 +2485,13 @@ them as toggles automatically, but nothing here builds a Team screen or a deep-l
app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an
oversight. oversight.
### Phase 7 — Discord: slash commands (`website` + `module-uo` + `docs`) — **DONE 2026-08-18** ### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`)
`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's `api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first
through it. command through it.
**Ships:** a working `/guild`, and the seam a module needs for its own commands. **Ships:** a working `/team`, and the seam a module needs for its own commands.
**THREE repos, not the plan's `website` + `bot` + `docs` — `bot` is not a repo.** It is a workspace
inside `website`, so the bot half lands in the same PR as the server half; `module-uo` joins instead,
because the command that proves the seam belongs to the module and not to core (see the amendment at
the head of [§7.1](#71-slash-command-registration)).
**Walked on the live rig before the PRs opened** — real ServUO + real sidecar (protocol 4) + the app
with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing
in for Discord. It proved the audience rung holding over the chat surface (guilds gated to `staff`:
anonymous and linked-player refused, linked admin served, same command), the Discord provider
resolving by `kind` on a deployment whose provider slug is `my-discord`, a banned account resolving as
unlinked, the disable nudge firing with its reason and degrading to a log line with no bot running,
and the pull emptying plus dispatch answering `unknown` for a module switched off at runtime.
**It found two defects, both folded in.** A refusal was posted PUBLICLY — ephemerality is fixed at the
deferral, before the handler has said anything, so the envelope's flag was read and ignored, and "not
shown to your account" announced a member's access level to the channel. And the refusal offered
linking on a shard gated to `staff`, where linking reaches `player` and stops.
**The bot got its first test harness.** It had no `test` script and no tests at all — CI ran
`npm ci --prefix bot` and nothing else — which was defensible while the bot only wired up its own
static commands. It is not defensible now that it merges a pulled set into a single all-or-nothing
registration and runs the interaction path, and phases 8 and 9 add more. `bot/test/` and a
`bot-tests` job replace `bot-install`.
### Phase 8 — Discord: notifications bridge (`website` + `bot`) ### Phase 8 — Discord: notifications bridge (`website` + `bot`)