feat(teams): phase 7 — the slash-command seam, and the bot's first tests #157

Merged
whitlocktech merged 2 commits from feature/teams-phase7-slash-commands into edge 2026-08-19 00:16:31 +00:00
Member

Phase 7 of docs/website/TEAMS.md. api.registerSlashCommands stops throwing: a module registers a command's definition and handler together, the bot pulls the definitions over the internal listener, and the handler runs here. Forced by §0.4 — the bot container has no modules volume — and the right boundary anyway.

Pairs with Module-uo#14 (the /guild command that proves the seam) and docs#161.

Org-lead decisions taken before building

  • module-uo registers /guild; core registers nothing. Core does not own the word for a Team — that is what deleted its Team pages in phase 3 — so it does not publish the noun in a channel either.
  • The bot gets a test harness (node --test), and bot-install becomes bot-tests.
  • The re-register nudge is its own endpoint, POST /internal/refresh-commands, not a ride on /internal/config — whose body carries the decrypted bot token.

What is here

Server. registries.js gains the staged registration, with validation that rejects anything Discord would reject as a batch — the bot registers the whole set in one REST.put, so one bad option type costs every command, the bot's own included. utils/slashCommands.js is the actor resolver, the access gate and the dispatcher. Two routes on the internal listener, both #swagger.ignored and behind requireInternalKey.

Bot. appInternalClient (base derived from SITE_INTERNAL_URL's origin, so no compose change), dynamicCommands (pull, merge, defer→dispatch→edit), and /internal/refresh-commands.

Five things the tree disagreed with the design about

  1. /teams/:slug does not exist, so §7.1's ${siteBaseUrl}/teams/${slug} cannot be built. The handler builds its own link from pageUrlTemplate — the same hole phase 6 found in the mail path.
  2. The Discord provider is found by auth_providers.kind, not the id slug. The id is operator-chosen; resolving by it would report "not linked" for every user on a deployment that named its provider anything else. Proved on the rig with a provider called my-discord.
  3. actor carries role beside isStaff. A module with its own audience rungs cannot place a caller from a boolean. It is the pair projectRoster's viewer already carried.
  4. "Deregistration is free" needed a second half. It holds across the restart an uninstall asks for, not across the runtime toggle: the registries have no removal path, so a disabled module would keep a live handler behind a command Discord still advertises. Liveness is now asked at both the pull and the dispatch.
  5. Commands cannot be namespaced under their owner — no dot in Discord's name grammar. Collisions are first-come with the holder named, and the one collision core cannot see (a module name against a bot built-in) is resolved by the bot in the module's disfavour.

The live rig

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), 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 + dispatch answering unknown for a module switched off at runtime.

It found the defect in the second commit. Ephemerality is fixed at the deferral, which happens before the handler has said anything — so the envelope's ephemeral was read and then ignored, and /guild's "not shown to your account" refusal was posted into the channel, announcing a member's access level to everyone in it. A private answer to a publicly deferred command now withdraws the deferred reply and follows up ephemerally.

Tests

1084 server (+18), 20 bot (new), 248 client. routes.manifest.json / routes.guards.json regenerated — the two internal routes, both requireInternalKey. Swagger is byte-identical, as it should be.

Not done here

  • BACKEND_DESIGN.md's mirrored api-route-inventory.json is stale from earlier phases (166 public / 2 internal against a live 208 / 4). I dropped the counts from the prose rather than silently re-snapshotting a file this phase did not touch — worth its own pass.
  • No Discord guild was involved. REST.put and a real interaction are the two things this walk could not exercise.

  • AI-assisted: Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz

Phase 7 of `docs/website/TEAMS.md`. `api.registerSlashCommands` stops throwing: a module registers a command's **definition and handler together**, the bot pulls the definitions over the internal listener, and the handler runs **here**. Forced by §0.4 — the bot container has no `modules` volume — and the right boundary anyway. Pairs with **Module-uo#14** (the `/guild` command that proves the seam) and **docs#161**. ## Org-lead decisions taken before building - **module-uo registers `/guild`; core registers nothing.** Core does not own the word for a Team — that is what deleted its Team pages in phase 3 — so it does not publish the noun in a channel either. - **The bot gets a test harness** (`node --test`), and `bot-install` becomes `bot-tests`. - **The re-register nudge is its own endpoint**, `POST /internal/refresh-commands`, not a ride on `/internal/config` — whose body carries the *decrypted* bot token. ## What is here **Server.** `registries.js` gains the staged registration, with validation that rejects anything Discord would reject *as a batch* — the bot registers the whole set in one `REST.put`, so one bad option type costs every command, the bot's own included. `utils/slashCommands.js` is the actor resolver, the access gate and the dispatcher. Two routes on the internal listener, both `#swagger.ignore`d and behind `requireInternalKey`. **Bot.** `appInternalClient` (base derived from `SITE_INTERNAL_URL`'s origin, so no compose change), `dynamicCommands` (pull, merge, defer→dispatch→edit), and `/internal/refresh-commands`. ## Five things the tree disagreed with the design about 1. **`/teams/:slug` does not exist**, so §7.1's `${siteBaseUrl}/teams/${slug}` cannot be built. The handler builds its own link from `pageUrlTemplate` — the same hole phase 6 found in the mail path. 2. **The Discord provider is found by `auth_providers.kind`, not the id slug.** The id is operator-chosen; resolving by it would report "not linked" for every user on a deployment that named its provider anything else. Proved on the rig with a provider called `my-discord`. 3. **`actor` carries `role` beside `isStaff`.** A module with its own audience rungs cannot place a caller from a boolean. It is the pair `projectRoster`'s viewer already carried. 4. **"Deregistration is free" needed a second half.** It holds across the restart an uninstall asks for, not across the runtime toggle: the registries have no removal path, so a disabled module would keep a live handler behind a command Discord still advertises. Liveness is now asked at both the pull and the dispatch. 5. **Commands cannot be namespaced under their owner** — no dot in Discord's name grammar. Collisions are first-come with the holder named, and the one collision core cannot see (a module name against a bot built-in) is resolved by the bot in the module's disfavour. ## The live rig 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), 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 + dispatch answering `unknown` for a module switched off at runtime. **It found the defect in the second commit.** Ephemerality is fixed at the **deferral**, which happens before the handler has said anything — so the envelope's `ephemeral` was read and then ignored, and `/guild`'s "not shown to your account" refusal was posted **into the channel**, announcing a member's access level to everyone in it. A private answer to a publicly deferred command now withdraws the deferred reply and follows up ephemerally. ## Tests 1084 server (+18), 20 bot (new), 248 client. `routes.manifest.json` / `routes.guards.json` regenerated — the two internal routes, both `requireInternalKey`. Swagger is byte-identical, as it should be. ## Not done here - `BACKEND_DESIGN.md`'s mirrored `api-route-inventory.json` is **stale from earlier phases** (166 public / 2 internal against a live 208 / 4). I dropped the counts from the prose rather than silently re-snapshotting a file this phase did not touch — worth its own pass. - No Discord guild was involved. `REST.put` and a real interaction are the two things this walk could not exercise. --- - [x] AI-assisted: Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
wtclaude added 2 commits 2026-08-19 00:09:28 +00:00
Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module
registers a command's DEFINITION and its HANDLER together, the bot pulls the
definitions over the internal listener and runs none of our code, and the
handler executes here — forced by the bot container having no `modules` volume,
and the right boundary anyway.

Registration validates what Discord would reject as a batch (names, description
lengths, the four option types, required-before-optional), because the bot
registers the whole set in one PUT and a single bad entry costs every command
including the bot's own. Commands are not namespaced under their owner — there
is no dot in Discord's name grammar — so collisions are first-come with the
holder named.

The dispatcher is the access boundary: `linked` has no Discord equivalent, so
the platform-side permission default can only ever be advertising. It resolves
the actor by `auth_providers.kind` rather than the id slug, treats a banned
account as unlinked, bounds a handler under the bot's own timeout, and keeps
`ok` outside the envelope so a handler cannot forge it.

Liveness is asked at both the pull and the dispatch. The registries have no
removal path, so a module an operator disables at runtime would otherwise keep
a live handler behind a command Discord still advertises.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(teams): a private answer has to be private, and the deferral decides that
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / bot-tests (pull_request) Successful in 8m50s
aca4d23179
Found on the live rig. Ephemerality is a property of the DEFERRAL, which happens
before the handler has said anything — so the envelope's `ephemeral` was being
read and then ignored, and `/guild`'s "not shown to your account" refusal was
posted into the channel, announcing a member's access level to everyone in it.

When the handler wants privacy the deferral did not give it, the deferred reply
is now withdrawn and the answer arrives as an ephemeral follow-up. The
interaction token stays valid, so this is a supported path and not a trick; the
cost is a "thinking..." that appears and vanishes. There is no reverse case — a
command deferred privately must not become public because a handler omitted a
flag — and a refusal is always private whatever the command's usual privacy.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 46f43a5fd6 into edge 2026-08-19 00:16:31 +00:00
whitlocktech deleted branch feature/teams-phase7-slash-commands 2026-08-19 00:16:31 +00:00
Sign in to join this conversation.
No description provided.