From c196d03d31c5467c8e0b4531cd392715312f982c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:53:56 -0500 Subject: [PATCH] =?UTF-8?q?docs(teams):=20phase=207=20as=20built=20?= =?UTF-8?q?=E2=80=94=20five=20amendments=20to=20=C2=A77.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command that proves the seam is the MODULE's `/guild`, not core's `/team`: §7.1 was written before phase 3 settled that Teams is a contract primitive with no core surface, and a core `/team` publishes the same invented noun that got core's Team pages deleted. Its deep link comes from `pageUrlTemplate` for the same reason — `/teams/:slug` does not exist. The re-register nudge is its own bot endpoint rather than a ride on `/internal/config`, whose body carries the decrypted bot token. `actor` carries `role` beside `isStaff`, since a module with its own audience rungs cannot place a caller from a boolean. And "deregistration is free" needed a second half: it holds across the restart an uninstall asks for, not across the runtime toggle, so liveness is asked at both the pull and the dispatch. MODULE_API.md stops saying `registerSlashCommands` throws and documents it — every member of 1.6.0 is live now. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 2 +- website/MODULE_API.md | 82 ++++++++++++++++++++++++++++++++++----- website/TEAMS.md | 64 ++++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 3dfa7c4..c55b761 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 diff --git a/website/MODULE_API.md b/website/MODULE_API.md index a124ccc..ab34b09 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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. diff --git a/website/TEAMS.md b/website/TEAMS.md index b5f466b..86bd0ac 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -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,24 @@ 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)). + +**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`)