Merge pull request 'docs(teams): phase 7 as built — five amendments to §7.1' (#161) from feature/teams-phase7-slash-commands into edge

Reviewed-on: #161
This commit is contained in:
2026-08-19 00:15:15 +00:00
3 changed files with 143 additions and 18 deletions

View File

@@ -674,7 +674,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on 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.** Every core URL — the public app plus the internal listener sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
Both are **core's**. An installed module's routes are in neither: they are in that module's own

View File

@@ -59,11 +59,9 @@ 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
> fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
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.
**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
@@ -301,7 +299,7 @@ api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([...]) // 1.6.0, throws until phase 7
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -492,10 +490,74 @@ 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
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
**`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
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
someone first types the command.
**`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.

View File

@@ -100,11 +100,14 @@ module fills with anything live. Core does not grow an SSE stack for this.
exactly two shared-secret HTTP channels:
- **app → bot**, `utils/botInternalClient.js``bot/src/internal/internal.routes.js`
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout,
never throws, always returns `{ ok, status, data, error }`.
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since
phase 7 `/internal/refresh-commands`), 4s timeout, never throws, always returns
`{ ok, status, data, error }`.
- **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
restart self-heals.
restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and
`/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
with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set
@@ -1669,6 +1672,42 @@ is already being built there.
### 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
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
@@ -2485,13 +2524,37 @@ 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
oversight.
### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`)
### Phase 7 — Discord: slash commands (`website` + `module-uo` + `docs`) — **DONE 2026-08-18**
`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first
command through it.
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
through it.
**Ships:** a working `/team`, and the seam a module needs for its own commands.
**Ships:** a working `/guild`, 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`)