From baffaa46c9094a95f8e68489669d0298e63d4cf1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 08:18:48 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(rust):=20identity=20=E2=80=94=20a=20li?= =?UTF-8?q?nk=20code=20from=20the=20game,=20and=20the=20Steam=20id=20insid?= =?UTF-8?q?e=20core's=20user=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1's identity link, site-side, and R13's first extension slot. A player types /link in game, the plugin hands them a six-character code privately, and they enter it here; the site records who owns which Steam account, and an operator sees that on core's own `/admin/users/:id` page. **The site is the author of record and the game holds nothing.** There is no per-account store in Rust that survives a wipe, and phase 7 needs the site authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A copy in the game would be a second thing to reconcile every wipe, for no question it could answer better. ## D24 — a code is minted by ONE server, so every server is asked Nothing in six characters says where it came from. The fleet is asked in turn and the first `link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent at the server that actually holds it. Asking the player to pick was rejected: a wrong pick would come back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous. **"Every reachable server refused" is not the same answer as "a server was unreachable."** Collapsing them tells a player whose server is down that their code is wrong — so they run /link again on that same server and are told the same thing for as long as it stays down. `unsure` is that case, and it says to try again rather than to fetch a new code. ## D23 — a Steam id another account holds is refused, never moved The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a silent move is an account takeover performed by typing six characters. The refusal names the holder, because the advice is unusable without it. The INSERT is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE that move — and the duplicate-key error is the refusal for the race the check above cannot close. The way out is `/unlink` in game, which reaches the site off the ingest feed rather than through a route (the plugin has no link to delete). D25 adds the other way out: staff can sever a link from the admin panel, for a player who cannot reach that Steam account in game. ## The slot, and the hole it found in this repo's own generator `admin.users.detail` is declared in `module.json` AND registered in `index.js` AND filled by the chunk — three places, because the server half and the client half are different registrations that share one name. `swaggerFragment.js` knew only about tier routers, so the two routes under `/admin/users/:id` were generated by nothing: a fragment that was internally consistent and described two routes fewer than the module serves. A slot's mount is core's and cannot be derived here, so it is a fourth constant beside `TIER_BASE` — held to account by the frozen-manifest job, which was verified to catch exactly this by removing the two paths and watching it fail. ## Smaller things worth knowing - **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page re-reads after its own write; it blanks while it re-reads, which is right here and is exactly what made it wrong for a poll. - **Every player-portal nav row needs an `icon`** — core draws one on every row, and the client suite says so. This module had no icons file until now, because the public header is text buttons. - The two new frame kinds are STAFF-only. Neither carries a code, but both name a Steam id beside a website account's activity, and that join is not a public fact about what happened on a server. - The link code route carries its own rate limiter rather than core's `accountChangeLimiter`: this is guessing somebody else's secret, not changing your own password, and a shared counter would let one policy set the other. Protocol 3 on all three declaration sites; 17 new tests, 136 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- client/src/api.js | 31 +- client/src/entry.jsx | 32 + client/src/icons.jsx | 49 ++ client/src/routes/admin/UserRustSections.jsx | 147 ++++ client/src/routes/player/Account.jsx | 191 +++++ module.json | 3 +- routes.manifest.json | 25 + server/catalogue.js | 9 +- server/db/purge.sql | 1 + server/db/schema.sql | 44 + server/index.js | 26 +- server/ingest.js | 29 + server/model/links/links.db.js | 147 ++++ server/model/links/links.model.js | 247 ++++++ server/router/admin/usersRust.controller.js | 71 ++ server/router/admin/usersRust.router.js | 73 ++ server/router/player/rust.controller.js | 123 ++- server/router/player/rust.router.js | 95 ++- server/scripts/swaggerFragment.js | 25 + server/sidecarClient.js | 36 +- server/swagger/doc.js | 95 +++ server/test/catalogue.test.js | 15 +- server/test/identityRoutes.test.js | 82 ++ server/test/ingest.test.js | 32 + server/test/links.test.js | 276 +++++++ swagger-fragment.json | 795 +++++++++++++++++++ 26 files changed, 2664 insertions(+), 35 deletions(-) create mode 100644 client/src/icons.jsx create mode 100644 client/src/routes/admin/UserRustSections.jsx create mode 100644 client/src/routes/player/Account.jsx create mode 100644 server/model/links/links.db.js create mode 100644 server/model/links/links.model.js create mode 100644 server/router/admin/usersRust.controller.js create mode 100644 server/router/admin/usersRust.router.js create mode 100644 server/test/identityRoutes.test.js create mode 100644 server/test/links.test.js diff --git a/client/src/api.js b/client/src/api.js index e435da6..429c8b1 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -74,6 +74,20 @@ export const playerServers = { list: () => req('/player/rust/servers'), } +// R1's identity link, from the signed-in player's side. +// +// **The code is the whole of what goes up.** The site has no idea which server +// minted it — nothing in six characters says — so the server half asks each +// configured server in turn (D24). A page that asked the player to pick would be +// asking them a question the site can answer itself, and a wrong pick would come +// back indistinguishable from a wrong code. +export const playerLinks = { + list: () => req('/player/rust/links'), + confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }), + remove: (steamId) => + req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }), +} + // ── admin ───────────────────────────────────────────────────────────────── // **`sidecarToken` goes up and never comes back.** The list answers `hasToken`, // and a save that omits the field leaves the stored credential alone — so an @@ -89,8 +103,23 @@ export const admin = { req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }), } +// ── the admin.users.detail extension slot ───────────────────────────────── +// +// The client half of R13's first slot. Core hands the component a `userId` and +// NOTHING else — not a client — so an extension builds its own bindings for the +// routes it registered at the other end (§3.5). These two are the only calls in +// this file whose path is core's rather than this module's: the resource is +// core's user, and the module's own segment is the part after it. +export const adminUserLinks = { + list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`), + remove: (userId, steamId) => + req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, { + method: 'DELETE', + }), +} + // Exported for the rare caller that needs the base itself — an ``, a // download link, an EventSource. Reach for `request` first. export { BASE, query } -export default { servers, playerServers, admin, BASE } +export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE } diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 9177e22..7fb8b18 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -20,7 +20,10 @@ import { registry, coreApiVersion } from './core.js' import Servers from './routes/public/Servers.jsx' import ServerDetail from './routes/public/ServerDetail.jsx' +import Account from './routes/player/Account.jsx' +import UserRustSections from './routes/admin/UserRustSections.jsx' import FooterStatus from './components/FooterStatus.jsx' +import { IconLink } from './icons.jsx' // The module id, exactly as `module.json` spells it. Core keys the registry by it // and prefixes every route path with it. @@ -54,11 +57,18 @@ const ID = 'rust' // // React Router ranks a static segment above a dynamic one, so `/rust` wins // against core's `/:slug` CMS route without depending on registration order. +// +// The player route is registered with an empty path for the same reason the +// public list is: `/player/rust` is the whole of what this module asks a player +// to do, and a landing page above one page is a page nobody wants. Core applies +// its own portal chrome and its own auth gate to the tier, so the component +// renders no layout and re-implements no check. registry.registerRoutes(ID, { public: [ { path: '', element: }, { path: 'servers/:id', element: }, ], + player: [{ path: '', element: }], }) // ── Nav ─────────────────────────────────────────────────────────────────── @@ -83,6 +93,18 @@ registry.registerNav(ID, { items: [{ label: 'Servers', to: '/rust' }], }) +// The player portal's row. It carries an `icon` because core draws one on every +// portal row — a row without one is the only text in a column of glyphs, and +// core used to render `` unguarded, which blanked the whole portal. +// +// No `order`: an unordered row appends after core's own rather than claiming a +// position it was not given. Account, appeals and notifications are what a player +// came to the portal for; linking a game account is what they do once. +registry.registerNav(ID, { + area: 'player', + items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }], +}) + // ── Extension slots ─────────────────────────────────────────────────────── // // Core declares a slot, only core may declare one, and at most one module may @@ -97,6 +119,16 @@ registry.registerNav(ID, { // purpose. registry.registerExtension(ID, 'site.footer.status', FooterStatus) +// R13's other slot, and the one that IS named in `module.json` — because it has +// a server half too (`server/router/admin/usersRust.router.js`). The two halves +// carry one name on purpose: a module that adds routes under +// `/api/v1/admin/users/:id` is the module with something to show on that page. +// +// Core passes `userId` and nothing else, so the component builds its own client +// for the routes the server half registered. It renders NOTHING for a user with +// no linked Steam account, which is most of them. +registry.registerExtension(ID, 'admin.users.detail', UserRustSections) + // `module.json`'s `coreApi` range was checked by the loader before this file was // ever served, so there is nothing to re-check here. Log it anyway: a mismatch // between the core that validated the manifest and the core that published this diff --git a/client/src/icons.jsx b/client/src/icons.jsx new file mode 100644 index 0000000..941eb3e --- /dev/null +++ b/client/src/icons.jsx @@ -0,0 +1,49 @@ +// ── The nav glyph for this module's player-portal row ───────────────────── +// +// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core +// renders whatever component a row carries, exactly as it renders its own rows' +// icons — and core's player portal draws a glyph on every row, so a row without +// one reads as breakage rather than as a design. The client suite asserts it. +// +// The public header is text buttons and carries no icons, which is why this file +// arrives with the player row and not before it. +// +// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by +// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes +// of presentation are not a component: putting them in the shared kit would +// freeze core's icon sizing into the contract, where changing it later would be a +// major bump. A module that wants to look like the nav it is in matches that nav. + +const Icon = ({ children }) => ( + +) + +/** + * A chain link — what the row is for. + * + * Not a gem, a person or a server: the portal's rows say what a player does + * there, and what a player does at `/player/rust` is link an account. Core's own + * neighbours are a gear (account), a shield (appeals) and a bell (notifications), + * so the row has to read as a verb in that company. + */ +export const IconLink = () => ( + + + + +) + +export default { IconLink } diff --git a/client/src/routes/admin/UserRustSections.jsx b/client/src/routes/admin/UserRustSections.jsx new file mode 100644 index 0000000..3313965 --- /dev/null +++ b/client/src/routes/admin/UserRustSections.jsx @@ -0,0 +1,147 @@ +// ── This module's fill for `admin.users.detail` ─────────────────────────── +// +// R13's first slot, and the phase criterion as an operator meets it: the Steam +// id inside core's own user page, under core's own security panel. +// +// **The slot hands over `userId` and nothing else** — not a client. So this file +// builds its own bindings for the routes the server half registered +// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of +// a call belong to the same module even when the URL between them is core's. +// +// **Most users have no Rust account, so most of the time this renders nothing.** +// A panel that announced "no linked Steam accounts" on every user page in a +// community that also runs a UO shard would be noise on the overwhelming +// majority of them. Silence is the honest answer to "what does the Rust module +// know about this person" when it is nothing. + +import { useCallback, useState } from 'react' +import { ago, count, duration } from '../../lib/format.js' +import { useAsync } from '../../core.js' +import api from '../../api.js' + +/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */ +function SectionTitle({ children }) { + return ( +
+ {children} +
+ ) +} + +/** One server's all-time totals for this player. */ +function ServerRow({ server }) { + return ( +
  • + {server.serverName} + + {count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)} + {server.wipes > 1 ? ` · ${server.wipes} wipes` : ''} + +
  • + ) +} + +/** One linked Steam account: who it is, when it was linked, and the way out. */ +function LinkPanel({ userId, link, onRemoved }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + async function unlink() { + setBusy(true) + setError('') + try { + await api.adminUserLinks.remove(userId, link.steamId) + await onRemoved() + } catch (err) { + setError(err.message || 'Could not unlink that account.') + setBusy(false) + } + } + + return ( +
    +
    +
    +
    + {link.name || link.steamId} +
    +
    + {link.steamId} · linked {ago(link.linkedAt)} + {link.serverId ? ` on ${link.serverId}` : ''} + {link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'} +
    + {/* Worth showing only when they differ: the name on the link is what + they were called when they linked, the other is what the game last + saw. A rename is the ordinary reason, and an operator reading a + support ticket wants both names. */} + {link.linkedName && link.name && link.linkedName !== link.name && ( +
    + Linked as “{link.linkedName}”. +
    + )} +
    + +
    + + {error && ( +

    {error}

    + )} + + {link.servers.length > 0 && ( +
      + {link.servers.map((server) => ( + + ))} +
    + )} +
    + ) +} + +export default function UserRustSections({ userId }) { + // Core's `useAsync` has no refresh, so a counter in the deps is how this + // re-reads after its own write (the same shape the player page uses). + const [reloads, setReloads] = useState(0) + const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads]) + const reload = useCallback(() => setReloads((n) => n + 1), []) + + // No `Loading` and no `ErrorState`, deliberately. This is a section inside + // somebody else's page: a spinner on every user page for a module most users + // have nothing to do with is worse than a section that appears when it has + // something, and a failure here must not replace core's own user detail with an + // error card. + if (!data || data.links.length === 0) return null + + return ( +
    + Rust + +
    + {data.links.map((link) => ( + + ))} +
    + +

    + A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is + recorded in the activity log — it is the way back for a player who linked the wrong account + and cannot reach it in game. +

    +
    + ) +} diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx new file mode 100644 index 0000000..b498c4d --- /dev/null +++ b/client/src/routes/player/Account.jsx @@ -0,0 +1,191 @@ +// ── The player's own Rust identity ──────────────────────────────────────── +// +// `/player/rust` — where a signed-in player links the Steam account they play +// on. It is the one page in this module a player is asked to *do* something on, +// and the thing they are doing matters more than it looks: from phase 7 the link +// is what in-game permissions are granted against, and from phase 13 it is what +// rewards are handed to. +// +// **A player route renders no layout of its own.** Core wraps `/player/*` in its +// own portal chrome, so this page starts at a heading — unlike the public pages +// in this module, which render `PublicLayout` themselves. +// +// The three-step instruction at the top is not decoration. Nothing else on the +// site tells a player that the code comes from the game, and a code field with no +// explanation is a code field nobody can use. + +import { useCallback, useState } from 'react' +import { ErrorState, Loading, useAsync } from '../../core.js' +import { ago, shortId } from '../../lib/format.js' +import api from '../../api.js' + +/** The code field, and the four answers it can produce. */ +function LinkForm({ onLinked }) { + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + async function submit(event) { + event.preventDefault() + if (!code.trim() || busy) return + + setBusy(true) + setMessage('') + setError('') + + try { + const result = await api.playerLinks.confirm(code.trim()) + setMessage( + result.already + ? 'That account was already linked to you.' + : `Linked ${result.link.name || shortId(result.link.steamId)}.`, + ) + setCode('') + await onLinked() + } catch (err) { + // Every refusal the server sends is already a sentence aimed at a player — + // "run /link again", "run /unlink in game", "try again in a minute" — so + // this renders it rather than replacing it with one of its own. The three + // are not interchangeable, and a page that flattened them into "could not + // link that code" would send a player back to the server that is down. + setError(err.message || 'Could not link that code.') + } finally { + setBusy(false) + } + } + + return ( +
    +
    + + +
    + + {message && ( +

    {message}

    + )} + {error && ( +

    {error}

    + )} +
    + ) +} + +/** One linked account, and the control that releases it. */ +function LinkRow({ link, onRemoved }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + async function remove() { + setBusy(true) + setError('') + try { + await api.playerLinks.remove(link.steamId) + await onRemoved() + } catch (err) { + setError(err.message || 'Could not unlink that account.') + setBusy(false) + } + } + + return ( +
  • +
    +
    + {link.name || shortId(link.steamId)} +
    +
    + {link.steamId} · linked {ago(link.linkedAt)} + {link.serverId ? ` on ${link.serverId}` : ''} +
    + {error && ( +

    {error}

    + )} +
    + +
  • + ) +} + +export default function Account() { + // `useAsync` rather than this module's `usePolled`: nothing here changes unless + // the person looking at it changes it, and a page that re-asked every twenty + // seconds would be asking a question nobody is waiting on. + // + // **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and + // that is the whole of its interface — so a counter in the deps is how a page + // re-reads after its own write. It blanks while it re-reads, which is right + // here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`). + const [reloads, setReloads] = useState(0) + const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads]) + const links = data ? data.links : [] + + const reload = useCallback(() => setReloads((n) => n + 1), []) + + return ( +
    +
    Steam accounts
    + +

    + Linking tells this site which Steam account is yours, so your play on our servers appears + under your name here — and so rewards and permissions the site hands out can reach you in + game. +

    + +
      +
    1. Join any of our Rust servers and type /link in chat.
    2. +
    3. The server replies with a six-character code, only you can see it, and it lasts five minutes.
    4. +
    5. Type it below. It works once.
    6. +
    + + + + {loading && } + {error && } + + {data && links.length > 0 && ( +
      + {links.map((link) => ( + + ))} +
    + )} + + {data && links.length > 0 && ( +

    + A link covers every server this community runs — a Steam account is one person wherever + they play, while stats are kept per server and per wipe. You can also type + {' '}/unlink in game to release one. +

    + )} + + {data && links.length === 0 && ( +

    + No Steam account is linked to this profile yet. +

    + )} +
    + ) +} diff --git a/module.json b/module.json index e71eb8b..0157d16 100644 --- a/module.json +++ b/module.json @@ -12,5 +12,6 @@ "admin": ["/rust"], "player": ["/rust"] }, - "capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes"] + "extensions": ["admin.users.detail"], + "capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"] } diff --git a/routes.manifest.json b/routes.manifest.json index 97906d9..18657bd 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -6,11 +6,31 @@ "path": "/api/v1/admin/rust/servers/:id", "tier": "public" }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/rust/links/:steamId", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/player/rust/links/:steamId", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/servers", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/rust/links", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/rust/links", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/player/rust/servers", @@ -51,6 +71,11 @@ "path": "/api/v1/admin/rust/servers/:id/test", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/player/rust/link", + "tier": "public" + }, { "method": "PUT", "path": "/api/v1/admin/rust/servers/:id", diff --git a/server/catalogue.js b/server/catalogue.js index 90a3d4e..cf255bf 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -69,9 +69,16 @@ const STAFF_KINDS = Object.freeze([ 'player.unbanned', 'player.login.attempt', 'player.approved', + // Protocol 3's two account frames. Neither carries a code — the code travels + // through the player, which is what makes typing it proof — but both name a + // Steam id ALONGSIDE a website account's activity, which is exactly the join a + // public page must not be able to make: "this player is that person" is a fact + // about somebody's identity, not about what happened on the server. + 'account.link.requested', + 'account.unlinked', ]) -/** Every kind protocol 2 defines. */ +/** Every kind protocol 3 defines. */ const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS]) const PUBLIC = new Set(PUBLIC_KINDS) diff --git a/server/db/purge.sql b/server/db/purge.sql index e179986..bb0e96d 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -19,6 +19,7 @@ -- it knows this module registered, because it is the side that knows which -- registrant owned what. +DROP TABLE IF EXISTS rust_account_links; DROP TABLE IF EXISTS rust_ingest_cursor; DROP TABLE IF EXISTS rust_presence; DROP TABLE IF EXISTS rust_events; diff --git a/server/db/schema.sql b/server/db/schema.sql index d32fbdb..3248d5f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -291,6 +291,50 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor ( ); +-- ── Who owns which Steam account ────────────────────────────────────────── +-- +-- R1's identity link, and the reason it is a table rather than a column on +-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed +-- by a Steam id, and it outlives every row this module writes about play. A +-- column here would be null for the overwhelming majority of players and would +-- be deleted by any sweep that pruned inactive ones. +-- +-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that +-- call in protocol 2 and it is the truth of the thing: a Steam account is one +-- person across every server an operator runs, where stats are per server and +-- per wipe. Linking on one server links for the fleet, because there is nothing +-- else it could honestly mean. +-- +-- **One Steam id, at most one user** — that is what the primary key buys, and it +-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may +-- do what in game and phase 13 makes it the thing that hands out loot; both are +-- grants against a Steam id, and both assume the question "whose is this?" has +-- exactly one answer. +-- +-- The reverse is deliberately NOT constrained: one website user may hold several +-- Steam accounts. People have a second account, or a family shares a site login, +-- and refusing that would be inventing a rule the game does not have. +-- +-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The +-- alternative is a row naming a user id that resolves to nobody, which every +-- read would then have to defend against. +CREATE TABLE IF NOT EXISTS rust_account_links ( + steam_id VARCHAR(32) NOT NULL PRIMARY KEY, + user_id INT NOT NULL, + -- What the player was called in game when they linked. A display name, kept + -- so an operator reading the admin panel sees a person rather than a number; + -- never used to identify anybody, because a Rust name changes on a whim. + name VARCHAR(191) NULL, + -- Which server minted the code. Not part of the identity — the link is + -- fleet-wide — but an operator asking "where did this come from" has no other + -- way to find out, and a support conversation starts there. + server_id VARCHAR(64) NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + KEY idx_rust_links_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + -- ── Changes to tables that already shipped ──────────────────────────────── -- -- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS` diff --git a/server/index.js b/server/index.js index 9692d31..80ebf2b 100644 --- a/server/index.js +++ b/server/index.js @@ -50,6 +50,7 @@ module.exports = function register(ctx, api) { const publicRust = require('./router/public/rust.router') const playerRust = require('./router/player/rust.router') const adminRust = require('./router/admin/rust.router') + const usersRust = require('./router/admin/usersRust.router') const boot = require('./boot') /* eslint-enable global-require */ @@ -78,6 +79,20 @@ module.exports = function register(ctx, api) { admin: { '/rust': adminRust }, }) + // R13's first extension slot (§2.4). Core declares `admin.users.detail` on + // `/api/v1/admin/users/:id` and we fill it; the router receives the parent's + // `req.params.id` through `mergeParams`. Core's own routes on the resource are + // declared before the slot is mounted, so core wins any path conflict — it owns + // the user, and this module owns what it can say about one. + // + // **It is declared twice, in two different places, on purpose.** This call is + // the SERVER half and `module.json`'s `extensions` array is held against it by + // the loader. The CLIENT half is `registry.registerExtension(ID, + // 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array — + // phase 1 found that the hard way with `site.footer.status`, which is a client + // slot and fails the load outright when named there. + api.registerExtension('admin.users.detail', usersRust) + // The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this // module's schema fragment, and BEFORE the HTTP listener binds — so a module // that must not serve traffic until it has warmed a cache gets that for free. @@ -92,14 +107,15 @@ module.exports = function register(ctx, api) { // Everything else this module will register — the Team provider, the event // triggers and audiences, the engagement seeds, the four event catalogues, the - // notification streams, the slash commands and the two extension slots — is - // deliberately absent. Each arrives with the phase that has something real to - // put in it. A registration with nothing behind it is worse than a missing one: - // a declared trigger nothing emits and a declared slot nothing fills are both - // surfaces an operator can configure and then wait on. + // notification streams and the slash commands — is deliberately absent. Each + // arrives with the phase that has something real to put in it. A registration + // with nothing behind it is worse than a missing one: a declared trigger + // nothing emits and a declared slot nothing fills are both surfaces an operator + // can configure and then wait on. log.info('registered', { version: require('../module.json').version, routes: 'public:/rust player:/rust admin:/rust', + extensions: 'admin.users.detail', }) } diff --git a/server/ingest.js b/server/ingest.js index 3aada97..7e878d5 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -34,6 +34,7 @@ const core = require('./core') const db = require('./model/events/events.db') +const links = require('./model/links/links.model') const sidecar = require('./sidecarClient') const log = core.logger('ingest') @@ -144,6 +145,34 @@ async function apply(serverId, item) { await db.touchPlayer(frame.steamId, frame.name || null) break + // ── Protocol 3: the one frame that changes something other than a counter ── + // + // `/unlink` in game severs the site's link, and it is the only way out of a + // link on the wrong account: the site REFUSES to move a Steam id another + // website account already holds (D23), so without this a player who linked + // while signed in as the wrong account would need staff. + // + // It arrives here rather than through a route because the plugin has nothing + // to delete — the site is the author of record and the game holds no link — + // so `/unlink` is the game reporting what the player asked for, applied off + // the feed like every other frame. + // + // **The authority is the Steam account itself.** Whoever is connected to the + // game as it is who it is, which is a stronger proof of ownership than the + // site can obtain any other way, so this is not scoped by website user. + case 'account.unlinked': + await db.touchPlayer(frame.steamId, frame.name || null) + await links.unlinkFromGame(frame.steamId) + break + + // Stored and counted as a sighting, nothing more. The code is deliberately + // NOT on this frame — it travels through the player — so there is nothing + // here to redeem and no pending state for the site to hold. It exists so an + // operator can see linking being used at all. + case 'account.link.requested': + await db.touchPlayer(frame.steamId, frame.name || null) + break + default: // Stored, not counted. Moderation frames, the server lifecycle, and // anything a newer protocol sends that this build does not understand. diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js new file mode 100644 index 0000000..295bb36 --- /dev/null +++ b/server/model/links/links.db.js @@ -0,0 +1,147 @@ +// ── SQL, and nothing else ───────────────────────────────────────────────── +// +// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its +// keep). Raw parameterised SQL through `core.query`, placeholders always. + +const core = require('../../core') + +const LINKS = 'rust_account_links' +const PLAYERS = 'rust_players' +const STATS = 'rust_player_wipe_stats' + +/** + * The link for one Steam id, or undefined. + * + * Joins core's `users` for the username, because every caller that asks "who + * owns this?" wants a name rather than an integer — and the one caller that + * refuses a re-link has to be able to say *whose* it is. + */ +async function getBySteamId(steamId) { + const rows = await core.query( + `SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId, + l.linked_at AS linkedAt, u.username + FROM ${LINKS} l + JOIN users u ON u.id = l.user_id + WHERE l.steam_id = ?`, + [steamId], + ) + return rows[0] +} + +/** Every Steam account one website user holds, newest first. */ +async function listForUser(userId) { + return core.query( + `SELECT steam_id AS steamId, user_id AS userId, name, server_id AS serverId, + linked_at AS linkedAt + FROM ${LINKS} + WHERE user_id = ? + ORDER BY linked_at DESC`, + [userId], + ) +} + +/** + * Record a link. + * + * **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in + * SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one + * website account to another — which, once phase 7 makes a link a privilege path + * and phase 13 makes it an entitlement, is an account takeover performed by + * typing a six-character code. The duplicate-key error is the refusal, and the + * controller turns it into a sentence. + */ +async function insert({ steamId, userId, name, serverId }) { + await core.query( + `INSERT INTO ${LINKS} (steam_id, user_id, name, server_id) + VALUES (?, ?, ?, ?)`, + [steamId, userId, name || null, serverId || null], + ) +} + +/** + * Remove a link the caller owns. + * + * Scoped by `user_id` in the statement rather than checked before it: a delete + * that reads, decides, then writes has a gap between the read and the write, and + * this way the ownership test and the deletion are the same operation. Answers + * how many rows went, so a caller can tell "removed" from "was not yours". + */ +async function removeOwned(steamId, userId) { + const result = await core.query( + `DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`, + [steamId, userId], + ) + return Number(result && result.affectedRows) || 0 +} + +/** + * Remove a link whoever holds it — the in-game `/unlink` path, and the staff + * unlink on the `admin.users.detail` panel (D25). + * + * Unscoped by user on purpose: neither caller is the link's owner and both have + * already established their authority another way. In game the authority is the + * Steam account itself — whoever is connected as it is who it is; on the admin + * panel it is the tier gate. Which is why the admin caller writes an + * `activity.log` entry naming the operator and this does not: it cannot tell the + * two apart, and a log line that guessed would be worse than none. + */ +async function removeBySteamId(steamId) { + const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId]) + return Number(result && result.affectedRows) || 0 +} + +/** + * Every link one user holds, enriched with what this module knows about that + * player — for the `admin.users.detail` panel. + * + * A LEFT JOIN, because a player can link an account and never play on it. An + * operator looking at that user should see the link, not an empty panel. + */ +async function listForUserWithPlayer(userId) { + return core.query( + `SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt, + p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen + FROM ${LINKS} l + LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id + WHERE l.user_id = ? + ORDER BY l.linked_at DESC`, + [userId], + ) +} + +/** + * Per-server all-time totals for one Steam id. + * + * The same rows the public leaderboard sums, grouped by server instead of + * filtered to one — so an operator sees a player across the fleet in one read. + * All-time, deliberately: an admin looking at a user wants their history, not + * this week's. + */ +async function statsForSteamId(steamId) { + return core.query( + `SELECT s.server_id AS serverId, srv.name AS serverName, + SUM(s.kills) AS kills, + SUM(s.deaths) AS deaths, + SUM(s.npc_kills) AS npcKills, + SUM(s.structures) AS structures, + SUM(s.playtime_sec) AS playtimeSec, + MAX(s.last_seen) AS lastSeen, + COUNT(DISTINCT s.wipe_id) AS wipes + FROM ${STATS} s + LEFT JOIN rust_servers srv ON srv.id = s.server_id + WHERE s.steam_id = ? + GROUP BY s.server_id, srv.name + ORDER BY SUM(s.playtime_sec) DESC`, + [steamId], + ) +} + +module.exports = { + getBySteamId, + listForUser, + listForUserWithPlayer, + insert, + removeOwned, + removeBySteamId, + statsForSteamId, +} diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js new file mode 100644 index 0000000..9962cab --- /dev/null +++ b/server/model/links/links.model.js @@ -0,0 +1,247 @@ +// ── Who owns which Steam account ────────────────────────────────────────── +// +// R1's identity link, site-side. The flow it sits in the middle of: +// +// 1. In game, a player types `/link`. The plugin mints a one-time code, tells +// them privately, and holds it in memory for five minutes. +// 2. On the website, the player types that code. This module asks the sidecar, +// which asks the plugin, which answers with the Steam id the code belongs +// to and drops it. +// 3. This file records the result. +// +// **The site is the author of record and the game holds nothing.** That is the +// one real difference from the UO bridge, which writes a tag onto the game +// account: there is no equivalent per-account store in Rust that survives a wipe, +// and phase 7 needs the site to be authoritative anyway — it pushes permissions +// INTO the game keyed by Steam id. A copy in the game would be a second thing to +// reconcile every wipe, for no question it could answer better. + +const core = require('../../core') +const db = require('./links.db') +const servers = require('../servers/servers.model') +const sidecar = require('../../sidecarClient') + +const log = core.logger('links') + +/** What a link looks like to any caller. Never carries a raw code. */ +function shape(row) { + if (!row) return null + return { + steamId: row.steamId, + name: row.name || null, + serverId: row.serverId || null, + linkedAt: row.linkedAt, + } +} + +/** The Steam accounts one website user holds. */ +async function listForUser(userId) { + return (await db.listForUser(userId)).map(shape) +} + +/** True when this user holds this Steam id. The ownership gate every player read uses. */ +async function owns(steamId, userId) { + const row = await db.getBySteamId(steamId) + return Boolean(row && Number(row.userId) === Number(userId)) +} + +/** + * Redeem a code against one server, and record the link. + * + * Answers a discriminated result rather than throwing, because every outcome + * here is a sentence somebody has to read: + * + * `{ ok: true, link }` — linked + * `{ ok: false, reason: 'rejected' }`— the game says that code is not good + * `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id + * `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer + * + * **`rejected` deliberately collapses "unknown" and "expired".** The plugin + * distinguishes them and an operator reading its log can too; a stranger typing + * codes must not learn which of the two they hit, because that is the difference + * between "keep guessing" and "guess faster". + */ +async function confirmOne({ server, code, userId }) { + const result = await sidecar.confirmLink(server, code) + + // The transport failed: the sidecar is unreachable, the game is not connected, + // or the reply never came. None of those is a verdict on the code, so the + // player is told to try again rather than that their code is wrong. + if (!result.ok) { + log.warn('link confirm did not reach the game', { server: server.id, status: result.status }) + return { ok: false, reason: 'offline' } + } + + const frame = result.data || {} + + // The plugin's own refusal. `frame.reason` is `unknown`, `expired` or + // `malformed`; it is logged and not surfaced (see the doc above). + if (frame.kind !== 'link.ok' || !frame.steamId) { + log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' }) + return { ok: false, reason: 'rejected' } + } + + const steamId = String(frame.steamId) + const held = await db.getBySteamId(steamId) + + // D23: refuse, and say whose it is. A move would transfer every permission and + // entitlement phases 7 and 13 hang off this link, on a code anybody in game + // could have run — and the player's way out is `/unlink` in game, which they + // can reach from the machine they are sitting at. + if (held) { + if (Number(held.userId) === Number(userId)) { + // Already theirs. Not an error: a player who pressed the button twice, or + // one whose code was confirmed on a request that then timed out. + return { ok: true, link: shape(held), already: true } + } + return { ok: false, reason: 'taken', username: held.username } + } + + try { + await db.insert({ + steamId, + userId, + name: frame.name || null, + serverId: server.id, + }) + } catch (err) { + // The race the PRIMARY KEY exists for: two confirmations of the same Steam + // id, interleaved between the check above and this write. The key refuses the + // second and it becomes the same refusal, rather than a 500. + if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) { + const now = await db.getBySteamId(steamId) + if (now && Number(now.userId) === Number(userId)) { + return { ok: true, link: shape(now), already: true } + } + return { ok: false, reason: 'taken', username: now && now.username } + } + throw err + } + + const link = shape(await db.getBySteamId(steamId)) + log.info('steam account linked', { steamId, userId, server: server.id }) + return { ok: true, link } +} + +/** + * Redeem a code against the fleet (D24). + * + * **A code is minted by ONE server and the player types six characters into a + * browser**, so the site cannot know which server it came from — nothing in the + * code says, and asking the player to pick would make a wrong guess + * indistinguishable from a wrong code, which is the one refusal that must not be + * ambiguous. So every enabled server is asked in turn and the first `link.ok` + * wins. The others answer `unknown` and nothing happens there: a code is only + * spent at the server that actually holds it. + * + * The loop stops early on `taken`, because that is a verdict about the Steam id + * rather than about this server — asking the rest of the fleet would produce the + * same answer more slowly. + * + * **"Every reachable server refused" is not the same answer as "a server was + * unreachable"**, and collapsing them is how a player who linked on the one + * server that is down gets told their code is wrong. `unsure` is that case, and + * the sentence it earns says to try again rather than to run `/link` again. + */ +async function redeem({ code, userId }) { + const fleet = await servers.listForPolling() + + if (fleet.length === 0) return { ok: false, reason: 'no-servers' } + + let refused = 0 + let unreachable = 0 + + for (const server of fleet) { + // Sequential, deliberately. In parallel every server would be asked even + // after one had already answered, and a code spent on the right server would + // still be travelling to five others — for a fleet of six and a five-minute + // TTL, there is nothing to win by racing them. + // eslint-disable-next-line no-await-in-loop + const result = await confirmOne({ server, code, userId }) + + if (result.ok || result.reason === 'taken') return result + + if (result.reason === 'offline') unreachable += 1 + else refused += 1 + } + + if (refused === 0) return { ok: false, reason: 'offline' } + if (unreachable > 0) return { ok: false, reason: 'unsure' } + + return { ok: false, reason: 'rejected' } +} + +/** Remove a link the caller owns. False when they did not hold it. */ +async function unlinkOwned(steamId, userId) { + return (await db.removeOwned(steamId, userId)) > 0 +} + +/** + * Remove a link whoever holds it. + * + * Two callers, both of which have already established their authority and + * neither of which is the link's owner: ingest applying an in-game `/unlink` + * (the authority is the Steam account — whoever is connected as it is who it + * is), and a staff unlink from the `admin.users.detail` panel (D25). + * + * It logs nothing about who asked, because the two callers record that + * differently: the admin one writes an `activity.log` entry naming the operator, + * and the game one has no operator to name. + */ +async function unlinkAnyOwner(steamId) { + return (await db.removeBySteamId(steamId)) > 0 +} + +/** + * Remove a link because the player asked in game. + * + * Called from ingest, off an `account.unlinked` event. + */ +async function unlinkFromGame(steamId) { + const removed = await unlinkAnyOwner(steamId) + if (removed) log.info('steam account unlinked in game', { steamId }) + return removed +} + +/** The admin panel's read: every link this user holds, with per-server totals. */ +async function forAdmin(userId) { + const links = await db.listForUserWithPlayer(userId) + + return Promise.all( + links.map(async (row) => ({ + steamId: row.steamId, + // The name on the LINK is what they were called when they linked; the one + // on `rust_players` is what the game last saw. They differ the moment + // somebody renames, and the newer one is the useful one to show. + name: row.playerName || row.name || null, + linkedName: row.name || null, + serverId: row.serverId || null, + linkedAt: row.linkedAt, + firstSeen: row.firstSeen || null, + lastSeen: row.lastSeen || null, + servers: (await db.statsForSteamId(row.steamId)).map((s) => ({ + serverId: s.serverId, + serverName: s.serverName || s.serverId, + kills: Number(s.kills) || 0, + deaths: Number(s.deaths) || 0, + npcKills: Number(s.npcKills) || 0, + structures: Number(s.structures) || 0, + playtimeSec: Number(s.playtimeSec) || 0, + wipes: Number(s.wipes) || 0, + lastSeen: s.lastSeen || null, + })), + })), + ) +} + +module.exports = { + shape, + listForUser, + owns, + confirmOne, + redeem, + unlinkOwned, + unlinkAnyOwner, + unlinkFromGame, + forAdmin, +} diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js new file mode 100644 index 0000000..f26590d --- /dev/null +++ b/server/router/admin/usersRust.controller.js @@ -0,0 +1,71 @@ +// ── The `admin.users.detail` slot's handlers ────────────────────────────── +// +// What an operator can see and do about one website user's Rust identity. The +// user id is the PARENT's — `req.params.id` off core's `/admin/users/:id` — and +// every statement here is scoped by it, so a panel opened on one user cannot +// read or write another's rows by editing a path segment. + +const core = require('../../core') + +const links = require('../../model/links/links.model') + +const log = core.logger('admin') + +/** + * GET /admin/users/:id/rust/links + * + * The linked Steam accounts and, per server, what this module knows about the + * player behind them — all-time rather than this wipe's, because an operator + * looking at a user wants their history and the public leaderboard already + * answers the other question. + * + * **An empty array is an answer.** Most users have no Rust link at all, and the + * panel renders nothing rather than an error for them. + */ +async function listLinks(req, res) { + try { + res.json({ links: await links.forAdmin(req.params.id) }) + } catch (err) { + log.error('failed to read a user’s Rust links', { error: err.message }) + res.status(500).json({ error: 'Failed to read this user’s Rust accounts' }) + } +} + +/** + * DELETE /admin/users/:id/rust/links/:steamId — staff sever a link (D25). + * + * **This is the counterweight to D23.** The site refuses to move a Steam id that + * another website account already holds, and the player's own way out is + * `/unlink` in game — which is no way out at all for somebody who has lost access + * to that Steam account, or to the site account holding it. Staff are that route. + * + * Scoped by the parent user id in the statement rather than checked first: the + * ownership test and the deletion are one operation, and a link that belongs to a + * different user answers 404 from the page it was not on. + */ +async function removeLink(req, res) { + const { steamId } = req.params + const userId = req.params.id + + try { + const removed = await links.unlinkOwned(steamId, userId) + + if (!removed) return res.status(404).json({ error: 'That account is not linked to this user' }) + + // The one write this panel has, so it is the one thing here worth an audit + // row: after phase 7 a link is what permissions are granted against, and + // "who severed it" stops being a curiosity. + await core.activity.log({ + req, + action: 'rust.account.unlink.staff', + detail: { steamId, userId: Number(userId) }, + }) + + return res.json({ unlinked: true }) + } catch (err) { + log.error('failed to unlink a Steam account', { error: err.message }) + return res.status(500).json({ error: 'Failed to unlink that account' }) + } +} + +module.exports = { listLinks, removeLink } diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js new file mode 100644 index 0000000..feed707 --- /dev/null +++ b/server/router/admin/usersRust.router.js @@ -0,0 +1,73 @@ +// ── The `admin.users.detail` extension slot ─────────────────────────────── +// +// R13's first slot, and the phase criterion in one file: *an operator sees the +// Steam id inside core's own user page*. +// +// MODULE_API.md §2.4's fourth mount shape — module routes hanging off a CORE +// resource. `/admin/users/:id` is a URL core owns and this module has something +// to say about it, so the routes cannot move behind a `/rust` prefix and cannot +// be registered anywhere else either. Core declares the slot; a module fills it, +// and only one module may. +// +// Three things about this router that are not true of the other three: +// +// • **`mergeParams: true`**, because the user id belongs to the parent. Without +// it `req.params.id` is undefined and every statement here silently scopes to +// nothing. +// • **The paths keep the module's own segment** (`/rust/links`, not `/links`). +// Core owns the resource and other modules may fill their own slots on other +// resources; a bare `/links` would be this module claiming a word on a URL it +// does not own. +// • **The gate is stricter than the admin tier's.** Core's users router is +// `requireRole('admin')` and the slot is mounted inside it, so editors and +// moderators never reach here — which is right for a surface that can sever +// what phases 7 and 13 grant against. +// +// The client half is registered under the SAME name (`registry.registerExtension` +// in `entry.jsx`) and builds its own client for these two routes; a slot passes a +// component `userId` and nothing else. + +const core = require('../../core') + +const express = core.express +const { param } = core.validator + +const usersRust = require('./usersRust.controller') +const { validate } = core.middleware + +// Same bound the player tier states, for the same reason: nothing but digits +// reaches a `WHERE steam_id = ?`. +const STEAM_ID_RE = /^[0-9]{5,32}$/ + +const usersRustRouter = express.Router({ mergeParams: true }) + +usersRustRouter.get( + '/rust/links', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s linked Steam accounts and their Rust record (admin only)' + // #swagger.description = 'Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminLinkList" } } } } */ + param('id').isInt(), + validate, + usersRust.listLinks, +) + +usersRustRouter.delete( + '/rust/links/:steamId', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Sever a user’s Steam link (admin only)' + // #swagger.description = 'Staff release a link on this user’s behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */ + /* #swagger.responses[404] = { description: 'Not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('steamId').matches(STEAM_ID_RE), + validate, + usersRust.removeLink, +) + +module.exports = usersRustRouter diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js index 463ae16..7240169 100644 --- a/server/router/player/rust.controller.js +++ b/server/router/player/rust.controller.js @@ -1,11 +1,27 @@ // ── Player · Rust — the handlers ────────────────────────────────────────── // -// See the router for why this tier is thin in phase 1. The one thing it must not -// do is reshape the list itself: it calls the same model the public tier does, so -// the two answers cannot drift while they are meant to be the same. +// Two things live here now: the server list as a signed-in caller sees it (phase +// 1's honest placeholder, which must not reshape the list — it calls the same +// model the public tier does so the two cannot drift), and R1's identity link. +// +// ── Every refusal is a sentence, and they are not interchangeable ───────── +// +// The link handler's whole job is turning a discriminated result into the right +// thing to tell a player, and the four wrong answers are wrong in different ways: +// +// • "that code is unknown or expired" → run `/link` again +// • "another account holds that Steam id" → run `/unlink` in game, or ask staff +// • "we could not reach a server" → try again in a minute; the code is fine +// • "no servers are configured" → nothing the player can do at all +// +// A player told to run `/link` again when the server their code came from was +// merely unreachable will run it again, get another code from the same +// unreachable server, and be told the same thing. That is the failure the +// `unsure` branch exists to prevent. const core = require('../../core') +const links = require('../../model/links/links.model') const servers = require('../../model/servers/servers.model') const log = core.logger('player') @@ -19,4 +35,103 @@ async function listServers(req, res) { } } -module.exports = { listServers } +/** GET /player/rust/links — the Steam accounts the caller holds. */ +async function listLinks(req, res) { + try { + res.json({ links: await links.listForUser(req.user.id) }) + } catch (err) { + log.error('failed to read a player’s links', { error: err.message }) + res.status(500).json({ error: 'Failed to read your linked accounts' }) + } +} + +/** + * POST /player/rust/link — redeem a code from `/link` in game. + * + * The fleet loop is the model's (D24); this maps its answer onto a status and a + * sentence. **A refused code is a 400 and an unreachable server is a 503**, + * because a client that cannot tell them apart cannot tell a player whether to + * try again or to go and get a new code. + */ +async function confirmLink(req, res) { + const code = String(req.body.code || '').trim() + + try { + const result = await links.redeem({ code, userId: req.user.id }) + + if (result.ok) { + // Logged on the player tier too, not only for admin writes: this is the + // moment a website account starts being able to hold permissions and + // entitlements in a game, and "when did this account become that Steam id" + // is a question an operator will eventually need answered. + await core.activity.log({ + req, + action: 'rust.account.link', + detail: { steamId: result.link.steamId, serverId: result.link.serverId }, + }) + + return res.json({ linked: true, link: result.link, already: Boolean(result.already) }) + } + + switch (result.reason) { + case 'taken': + // Naming the holder is deliberate and it is not a leak: the player is + // signed in, the account named is one they may well own, and without the + // name the advice ("sign in as that account, or ask staff") is unusable. + return res.status(409).json({ + error: result.username + ? `That Steam account is already linked to ${result.username}. Run /unlink in game to release it.` + : 'That Steam account is already linked to another website account. Run /unlink in game to release it.', + }) + + case 'unsure': + return res.status(503).json({ + error: + 'One of the servers could not be reached, so that code could not be checked. ' + + 'Your code is still good — try again in a minute.', + }) + + case 'offline': + return res.status(503).json({ + error: 'The game servers are unreachable right now — try again in a minute.', + }) + + case 'no-servers': + return res.status(503).json({ error: 'No Rust servers are configured on this site yet.' }) + + default: + return res.status(400).json({ + error: 'That code is unknown or has expired. Type /link in game for a new one.', + }) + } + } catch (err) { + log.error('failed to confirm a link code', { error: err.message }) + return res.status(500).json({ error: 'Failed to confirm that code' }) + } +} + +/** + * DELETE /player/rust/links/:steamId — release a link the caller holds. + * + * Scoped to the caller inside the statement, so "not linked" and "not yours" + * answer the same 404 — a signed-in stranger must not be able to discover which + * Steam ids are linked by deleting them one at a time. + */ +async function removeLink(req, res) { + const { steamId } = req.params + + try { + const removed = await links.unlinkOwned(steamId, req.user.id) + + if (!removed) return res.status(404).json({ error: 'That account is not linked to you' }) + + await core.activity.log({ req, action: 'rust.account.unlink', detail: { steamId } }) + + return res.json({ unlinked: true }) + } catch (err) { + log.error('failed to unlink', { error: err.message }) + return res.status(500).json({ error: 'Failed to unlink that account' }) + } +} + +module.exports = { listServers, listLinks, confirmLink, removeLink } diff --git a/server/router/player/rust.router.js b/server/router/player/rust.router.js index 2119049..ad5195c 100644 --- a/server/router/player/rust.router.js +++ b/server/router/player/rust.router.js @@ -4,38 +4,109 @@ // sits behind `noindex, requireAuth`, so every handler here has a signed-in user // and none of them re-implements that check. // -// ── Why this tier exists in phase 1, and what it honestly holds ─────────── +// ── Why this tier exists in phase 1, and what it holds now ──────────────── // // R14 puts this module on all three tiers from the start, and the loader holds // `module.json`'s `mounts` against what is actually registered in **both** // directions — a declared prefix that never gets a router fails the load. So the // declaration and the registration land together or not at all. // -// What this tier will carry is the signed-in view of a server: the viewer's own -// linked Steam identity, their own presence, their own entitlements. None of that -// exists yet — identity is a later phase — so the one route here answers the -// server list as the signed-in caller sees it, which is currently the same list -// the public tier serves. +// Phase 1 said this tier would carry the signed-in view of a server — the +// viewer's own linked Steam identity, their own presence, their own entitlements +// — and that identity was a later phase. This is that phase: `/links`, `/link` +// and `DELETE /links/:steamId` are R1, and everything phases 7 and 13 hand out is +// hung off the row they write. // -// That is deliberately a real route and not a placeholder: it is the URL the app -// and the SPA will call, and it starts answering correctly now rather than -// changing address later. What it must not become is a second copy of the public -// shape — it delegates to the same model, so the two cannot drift. +// `/servers` stays what it was: the same list the public tier serves, answered on +// the authenticated tier so per-player detail can be added without moving the +// address. It delegates to the same model, so the two cannot drift. const core = require('../../core') const express = core.express -const servers = require('./rust.controller') +const { body, param } = core.validator + +const rust = require('./rust.controller') +const { validate, rateLimit } = core.middleware const playerRustRouter = express.Router() +// A Steam id as the game states it — `BasePlayer.UserIDString`, a 17-digit +// SteamID64. Bounded rather than pinned at 17 because the column is a string and +// a test rig's ids are shorter; what matters is that nothing but digits reaches a +// `WHERE steam_id = ?`. +const STEAM_ID_RE = /^[0-9]{5,32}$/ + +/** + * R1 requires the link code be rate-limited, and this is where that lands. + * + * The code is six characters from a 32-glyph alphabet, so guessing one is a + * 1-in-10⁹ shot — but only while the guesser is made to pay for each attempt. + * Ten per quarter-hour per IP turns that into centuries; without it a script + * could work through the space in an afternoon, and phases 7 and 13 make the + * prize a set of in-game permissions and entitlements rather than a cosmetic + * badge. + * + * Its own limiter rather than core's `accountChangeLimiter`: this is guessing + * somebody else's secret, not changing your own password, and sharing a counter + * would mean one of the two silently sets the policy for the other. + */ +const linkLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + label: 'rust-link-code', + message: 'Too many link attempts. Please try again later.', +}) + playerRustRouter.get( '/servers', // #swagger.tags = ['Player · Rust'] // #swagger.summary = 'The Rust servers, for a signed-in player' // #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.' /* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */ - servers.listServers, + rust.listServers, +) + +playerRustRouter.get( + '/links', + // #swagger.tags = ['Player · Rust'] + // #swagger.summary = 'The Steam accounts the caller has linked' + // #swagger.description = 'Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkList" } } } } */ + rust.listLinks, +) + +playerRustRouter.post( + '/link', + // #swagger.tags = ['Player · Rust'] + // #swagger.summary = 'Link a Steam account with a one-time code from /link in game' + // #swagger.description = 'The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkResult" } } } } */ + /* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'That Steam account is linked to another website account', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many link attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'A server could not be reached — the code is still good', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + linkLimiter, + body('code').isString().trim().isLength({ min: 4, max: 32 }), + validate, + rust.confirmLink, +) + +playerRustRouter.delete( + '/links/:steamId', + // #swagger.tags = ['Player · Rust'] + // #swagger.summary = 'Release a Steam account the caller has linked' + // #swagger.description = 'Removes the caller’s own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */ + /* #swagger.responses[404] = { description: 'Not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('steamId').matches(STEAM_ID_RE), + validate, + rust.removeLink, ) module.exports = playerRustRouter diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js index 94a9497..09a6c97 100644 --- a/server/scripts/swaggerFragment.js +++ b/server/scripts/swaggerFragment.js @@ -60,6 +60,22 @@ const TIER_BASE = { player: '/api/v1/player', } +// MODULE_API.md §2.4's slot table, and the FOURTH base this generator needs. +// +// Phase 6 found the hole: a slot router is not registered under a tier, so the +// loop below could not see it and the two routes it serves were generated by +// nothing — a fragment that was internally consistent and silently described two +// routes fewer than the module serves. The frozen-manifest check would have +// caught it (every route must have an operation), which is precisely why that +// check exists; this is the fix it points at. +// +// A slot's mount is CORE's, not ours, so it cannot be derived from anything in +// this repo. That makes it the same kind of constant as `TIER_BASE` above, and it +// is held to account the same way: by a real core in the frozen-manifest job. +const SLOT_MOUNT = { + 'admin.users.detail': '/api/v1/admin/users/:id', +} + /** * Run `register()` with a recording api and return `[{ file, prefix, what }]`. * @@ -88,6 +104,15 @@ function mountedRouters() { } } + // A filled slot is a mount too. Registered through a different call, mounted + // on a resource core owns, and — unlike a tier router — carrying the parent's + // `:id` in its own base path. + for (const { slot, router } of api.record.extensions || []) { + const mount = SLOT_MOUNT[slot] + if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`) + mounts.push({ router, prefix: mount, what: `slot ${slot}` }) + } + return mounts.map(({ router, prefix, what }) => { const file = fileOf(router) if (!file) { diff --git a/server/sidecarClient.js b/server/sidecarClient.js index ab52faa..8683704 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -52,18 +52,19 @@ const TIMEOUT_MS = 12000 * here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge * plugin, and `protocol` in its `overlay.toml`. * - * **2 — the read path.** The bump lands here in the same change as the emitters, - * even though this module does not yet consume any of the new frames: the - * sidecar refuses a client declaring a different version with a `409`, so a - * module left on 1 would stop being able to read the server board it has been - * reading all along. A constant that lags the deployment is not a safe default; - * it is an outage with a version number on it. + * **3 — identity.** Protocol 2 was the read path; 3 adds the first message the + * WEBSITE originates (`link.confirm`) and the two account frames the plugin + * emits beside it. The bump lands here in the same change as the emitters, + * because the sidecar refuses a client declaring a different version with a + * `409`: a module left on 2 would stop being able to read the server board it + * has been reading all along. A constant that lags the deployment is not a safe + * default; it is an outage with a version number on it. * * It is sent on every request as `X-RustLink-Version`, which turns a mismatched * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ -const PROTOCOL_VERSION = 2 +const PROTOCOL_VERSION = 3 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { @@ -189,6 +190,26 @@ const feed = (server, since, limit = 200) => /** Where the sidecar's history currently ends. What a new server's cursor starts at. */ const feedTail = (server) => request(server, '/feed') +/** + * Redeem a one-time link code against one server (protocol 3). + * + * **The only call in this file that is not a GET**, and the only one that asks + * the game a question rather than reading what it already said. The sidecar + * forwards the code to the plugin, which holds the pending codes in memory, and + * hands back what it answers. + * + * **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are + * both answers — the sidecar reserves its own failures for the transport (503 + * when the game is down, 504 when it is up and silent) — and the caller has to + * tell "that code is wrong" from "the game never replied" to say the right thing + * to a player. So the discrimination happens on `data.kind`, not on `ok`. + * + * A code is spent on the plugin's FIRST lookup whether or not it turns out to be + * expired, so this must never be called speculatively for its answer alone. + */ +const confirmLink = (server, code) => + request(server, '/link/confirm', { method: 'POST', body: { code } }) + module.exports = { TIMEOUT_MS, PROTOCOL_VERSION, @@ -199,5 +220,6 @@ module.exports = { boards, feed, feedTail, + confirmLink, joinUrl, } diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 696f8ec..ca16863 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -100,6 +100,101 @@ module.exports = { stale: { type: 'boolean', example: false }, }, }, + RustLink: { + type: 'object', + description: 'One Steam account linked to a website user. Never carries a code.', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { + type: 'string', + nullable: true, + description: 'What the player was called in game when they linked. A display name only — a Rust name changes on a whim and nothing identifies anybody by it.', + example: 'Wanderer', + }, + serverId: { + type: 'string', + nullable: true, + description: 'Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts.', + example: 'main', + }, + linkedAt: { type: 'string', format: 'date-time' }, + }, + }, + RustLinkList: { + type: 'object', + description: 'The Steam accounts one website user holds (GET /player/rust/links).', + properties: { + links: { type: 'array', items: { $ref: '#/components/schemas/RustLink' } }, + }, + }, + RustLinkRequest: { + type: 'object', + required: ['code'], + properties: { + code: { + type: 'string', + description: 'The six-character code /link handed the player in game. Good for five minutes, and it works once.', + example: 'K7M2PQ', + }, + }, + }, + RustLinkResult: { + type: 'object', + description: 'The result of redeeming a code.', + properties: { + linked: { type: 'boolean', example: true }, + link: { $ref: '#/components/schemas/RustLink' }, + already: { + type: 'boolean', + description: 'True when this Steam id was already linked to the caller — a second press of the button, not an error.', + example: false, + }, + }, + }, + RustAdminLinkList: { + type: 'object', + description: 'One user’s Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links).', + properties: { + links: { + type: 'array', + items: { + type: 'object', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { + type: 'string', + nullable: true, + description: 'What the game last saw this player called, falling back to the name recorded at link time.', + example: 'Wanderer', + }, + linkedName: { type: 'string', nullable: true, example: 'Wanderer' }, + serverId: { type: 'string', nullable: true, example: 'main' }, + linkedAt: { type: 'string', format: 'date-time' }, + firstSeen: { type: 'string', format: 'date-time', nullable: true }, + lastSeen: { type: 'string', format: 'date-time', nullable: true }, + servers: { + type: 'array', + description: 'All-time totals per server, summed across every wipe.', + items: { + type: 'object', + properties: { + serverId: { type: 'string', example: 'main' }, + serverName: { type: 'string', example: 'Main · Vanilla' }, + kills: { type: 'integer', example: 41 }, + deaths: { type: 'integer', example: 37 }, + npcKills: { type: 'integer', example: 120 }, + structures: { type: 'integer', example: 64 }, + playtimeSec: { type: 'integer', example: 43200 }, + wipes: { type: 'integer', example: 2 }, + lastSeen: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, RustSidecarProbe: { type: 'object', description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).', diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index c944ba8..b718ec7 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -28,7 +28,7 @@ test('an unknown kind is not public — the default is deny', () => { assert.equal(catalogue.isPublic('player.location'), false) }) -test('nothing carrying an IP address or a report is public', () => { +test('nothing carrying an IP address, a report or an identity is public', () => { for (const kind of [ 'player.login.attempt', 'player.approved', @@ -36,6 +36,11 @@ test('nothing carrying an IP address or a report is public', () => { 'player.unbanned', 'player.reported', 'entity.destroyed', + // Protocol 3. A link request on a public killfeed would tell everyone which + // Steam id is about to become a named website account, and an unlink would + // say when somebody stopped being one. + 'account.link.requested', + 'account.unlinked', ]) { assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`) assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`) @@ -82,13 +87,13 @@ test('every kind is classified exactly once', () => { assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length) }) -test('the classification covers exactly the kinds protocol 2 defines', () => { +test('the classification covers exactly the kinds protocol 3 defines', () => { // The spec lives in another repository, so the list is restated here rather // than parsed — and restating it is the point: adding a kind to the protocol // without deciding who may see it has to fail somewhere, and this is where. // // Sourced from docs/rust-link/PROTOCOL.md §8.4. - const PROTOCOL_2 = [ + const PROTOCOL_3 = [ 'player.connected', 'player.disconnected', 'player.respawned', @@ -104,7 +109,9 @@ test('the classification covers exactly the kinds protocol 2 defines', () => { 'server.wipe', 'server.initialized', 'server.shutdown', + 'account.link.requested', + 'account.unlinked', ] - assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_2].sort()) + assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort()) }) diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js new file mode 100644 index 0000000..ee0a18b --- /dev/null +++ b/server/test/identityRoutes.test.js @@ -0,0 +1,82 @@ +// ── The shape of the identity surface ───────────────────────────────────── +// +// Three properties that are invisible in review and expensive in production: +// +// • **the link route is rate-limited** (R1). Six characters from a 32-glyph +// alphabet is a good code only while a guesser is made to pay per attempt, +// and once phase 7 grants permissions against a link, guessing one is a +// privilege-escalation path rather than a nuisance. +// • **the extension router merges its parent's params**. Without +// `mergeParams`, `req.params.id` is `undefined` and every statement in that +// panel silently scopes to no user — a panel that reads as "this user has no +// Rust account" for everybody. +// • **the extension's paths keep the module's own segment.** Core owns +// `/admin/users/:id`; a bare `/links` would be this module claiming a word on +// a URL it does not own, and the next module to fill a slot would collide. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx, fakeApi } = require('./_fakes') + +function register(ctx = fakeCtx()) { + require('../core')._reset() + const api = fakeApi() + require('../index')(ctx, api) + return api +} + +/** `[{ method, path, handlers }]` for one express router. */ +function routesOf(router) { + return router.stack + .filter((layer) => layer.route) + .map((layer) => ({ + path: layer.route.path, + method: Object.keys(layer.route.methods)[0].toUpperCase(), + handlers: layer.route.stack.map((s) => s.handle), + })) +} + +test('the player tier serves the three identity routes, and nothing else new', () => { + const api = register() + const routes = routesOf(api.record.routes.player['/rust']) + + assert.deepEqual( + routes.map((r) => `${r.method} ${r.path}`).sort(), + ['DELETE /links/:steamId', 'GET /links', 'GET /servers', 'POST /link'], + ) +}) + +test('redeeming a code is rate-limited, and by a limiter of its own', () => { + const api = register() + const post = routesOf(api.record.routes.player['/rust']).find((r) => r.method === 'POST') + + // The fake's `rateLimit` hands back a pass-through carrying the options it was + // given, so the policy itself is assertable — a limiter that was quietly + // removed, or one built with core's `accountChangeLimiter` shared counter, + // both fail here. + const limiter = post.handlers.find((h) => h.options && h.options.label === 'rust-link-code') + + assert.ok(limiter, 'POST /link must carry its own rate limiter (R1)') + assert.equal(limiter.options.max, 10) + assert.equal(limiter.options.windowMs, 15 * 60 * 1000) + + // First in the chain: a limiter behind the validator would let an attacker + // spend the cheap half of the request unbounded. + assert.equal(post.handlers[0], limiter) +}) + +test('the admin.users.detail router merges the parent’s params and keeps its own segment', () => { + const api = register() + const slot = api.record.extensions.find((e) => e.slot === 'admin.users.detail') + + assert.ok(slot, 'the server half of admin.users.detail must be registered') + assert.equal(slot.router.mergeParams, true) + + const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort() + assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links']) + + for (const route of routesOf(slot.router)) { + assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`) + } +}) diff --git a/server/test/ingest.test.js b/server/test/ingest.test.js index 2fc4fc7..7fbcdb0 100644 --- a/server/test/ingest.test.js +++ b/server/test/ingest.test.js @@ -327,3 +327,35 @@ test('a board replaces presence rather than appending to it', async () => { assert.match(presence[0].sql, /^DELETE FROM rust_presence/) assert.match(presence[1].sql, /INSERT INTO rust_presence/) }) + +// ── Protocol 3: the frame that changes something other than a counter ───── + +test('an in-game /unlink severs the site link, scoped by Steam id alone', async () => { + const rec = withRecorder() + const ingest = require('../ingest') + + await ingest.apply('main', item('account.unlinked', { steamId: '7656', name: 'Wanderer', origin: 'in-game' })) + + const del = rec.statements.find((st) => st.sql.trim().toUpperCase().startsWith('DELETE')) + + // It arrives on the FEED rather than through a route because the plugin has no + // link to delete — the site is the author of record. And it is the only way out + // of a link on the wrong account, because the site refuses to move a Steam id + // another account already holds (D23). + assert.ok(del, 'an unlink frame must delete the link') + assert.ok(del.sql.includes('rust_account_links')) + assert.deepEqual(del.params, ['7656']) +}) + +test('asking for a code links nothing — the code does not travel on the wire', async () => { + const rec = withRecorder() + const ingest = require('../ingest') + + await ingest.apply('main', item('account.link.requested', { steamId: '7656', name: 'Wanderer', ttlSec: 300 })) + + // The frame exists so an operator can see linking being used. Nothing about it + // is redeemable: the code travels through the player, which is what makes + // typing it proof that they are the one who asked. + assert.equal(rec.touching('rust_account_links').length, 0) + assert.equal(rec.touching('rust_players').length, 1) +}) diff --git a/server/test/links.test.js b/server/test/links.test.js new file mode 100644 index 0000000..a6a5731 --- /dev/null +++ b/server/test/links.test.js @@ -0,0 +1,276 @@ +// ── Identity: the fleet loop and the refusal ────────────────────────────── +// +// Two things in this file are worth more than the rest, and both are about +// telling answers apart that a naive implementation collapses: +// +// • **A code is minted by ONE server** and the player types six characters into +// a browser. Every server is asked in turn (D24), and "every reachable server +// said no" is NOT the same answer as "a server could not be reached" — the +// second is the case where the player's code is perfectly good and the advice +// "run /link again" is useless, because it sends them back to the server that +// is down. +// +// • **A Steam id another account holds is refused, never moved** (D23). Once +// phase 7 grants permissions against a link and phase 13 hangs entitlements +// off it, a silent move is an account takeover performed by typing six +// characters. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +/** + * Installs a ctx whose `db.query` answers from a small script. + * + * `rows` is consulted by the first word of the statement, which is as much SQL as + * these tests should know: the point of each one is the decision the model makes, + * not the shape of a SELECT it delegates. + */ +function withCore({ select = [], onInsert = null } = {}) { + const queries = [] + + const ctx = fakeCtx({ + db: { + query: (sql, params) => { + queries.push({ sql, params }) + + const verb = sql.trim().split(/\s+/)[0].toUpperCase() + + if (verb === 'SELECT') { + const next = Array.isArray(select) ? select.shift() : select + return Promise.resolve(next || []) + } + + if (verb === 'INSERT' && onInsert) return onInsert(params) + + return Promise.resolve({ affectedRows: 1 }) + }, + pool: {}, + }, + }) + + require('../core')._reset() + require('../core').init(ctx) + + return { ctx, queries } +} + +/** A fleet of `n` servers, and a sidecar that answers from a script. */ +function fleetOf(replies) { + const servers = require('../model/servers/servers.model') + const sidecar = require('../sidecarClient') + + const asked = [] + const ids = Object.keys(replies) + + servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' })) + + sidecar.confirmLink = async (server, code) => { + asked.push({ server: server.id, code }) + return replies[server.id] + } + + return asked +} + +/** The two replies a reachable sidecar can carry, and the one it cannot. */ +const linkOk = (steamId, name) => ({ ok: true, status: 'ok', data: { kind: 'link.ok', steamId, name } }) +const linkRefused = { ok: true, status: 'ok', data: { kind: 'link.error', reason: 'unknown' } } +const unreachable = { ok: false, status: 'transport-error', data: null } + +test('every server is asked until one recognises the code, and the one that answered is recorded', async () => { + const { queries } = withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'b' }]] }) + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkRefused, b: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, true) + assert.equal(result.link.steamId, '7656') + + // Both servers were asked, in order, with the same code — and the loop stopped + // at the one that said yes. + assert.deepEqual(asked, [{ server: 'a', code: 'K7M2PQ' }, { server: 'b', code: 'K7M2PQ' }]) + + // The server that minted it is stored. It is not part of the identity — a link + // is fleet-wide — but it is where a support conversation starts. + const insert = queries.find((q) => q.sql.trim().toUpperCase().startsWith('INSERT')) + assert.deepEqual(insert.params, ['7656', 4, 'Wanderer', 'b']) +}) + +test('a server after the one that answered is never asked', async () => { + withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] }) + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused }) + + await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + // A code is spent on the plugin's FIRST lookup, so carrying on after a yes + // would be asking four other game hosts to look up a secret that has already + // been redeemed. + assert.deepEqual(asked.map((a) => a.server), ['a']) +}) + +test('a Steam id another account holds is refused, not moved — and the loop stops', async () => { + // The whole of D23 in one assertion. The holder is named because the player is + // signed in and the advice ("sign in as that account, or run /unlink") is + // unusable without it. + withCore({ select: [[{ steamId: '7656', userId: 9, username: 'someone-else' }]] }) + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, false) + assert.equal(result.reason, 'taken') + assert.equal(result.username, 'someone-else') + + // Asking the rest of the fleet would answer the same question more slowly: the + // verdict is about the Steam id, not about this server. + assert.deepEqual(asked.map((a) => a.server), ['a']) +}) + +test('a code already redeemed by the SAME user is a success, not an error', async () => { + withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]] }) + const links = require('../model/links/links.model') + + fleetOf({ a: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + // A player who pressed the button twice, or whose confirmation was applied on a + // request that then timed out. Reporting that as a failure would send them to + // run `/link` again for a link they already have. + assert.equal(result.ok, true) + assert.equal(result.already, true) +}) + +test('"every reachable server refused" is not the same answer as "a server was unreachable"', async () => { + withCore() + const links = require('../model/links/links.model') + + fleetOf({ a: linkRefused, b: unreachable }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + // The failure this prevents: a player linked on the server that is down, is + // told their code is wrong, runs `/link` again on that same server, and is told + // the same thing for as long as it stays down. + assert.equal(result.reason, 'unsure') +}) + +test('a fleet nobody can reach is offline, and a fleet that all refused is a bad code', async () => { + withCore() + let links = require('../model/links/links.model') + + fleetOf({ a: unreachable, b: unreachable }) + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'offline') + + withCore() + links = require('../model/links/links.model') + + fleetOf({ a: linkRefused, b: linkRefused }) + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'rejected') +}) + +test('a site with no servers configured says so rather than that the code is wrong', async () => { + withCore() + const links = require('../model/links/links.model') + + fleetOf({}) + + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'no-servers') +}) + +test('two confirmations of one Steam id race into the primary key, not into a 500', async () => { + // The window the PRIMARY KEY exists for: both requests read "not linked", both + // write. The second insert is refused by the key, and the refusal has to become + // the same sentence the check above produces — otherwise one of two players + // pressing a button at the same moment gets an internal error. + const dup = Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' }) + + withCore({ + select: [[], [{ steamId: '7656', userId: 9, username: 'someone-else' }]], + onInsert: () => Promise.reject(dup), + }) + const links = require('../model/links/links.model') + + fleetOf({ a: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, false) + assert.equal(result.reason, 'taken') + assert.equal(result.username, 'someone-else') +}) + +test('the same race, won by the caller, is a success', async () => { + const dup = Object.assign(new Error('duplicate'), { errno: 1062 }) + + withCore({ + select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]], + onInsert: () => Promise.reject(dup), + }) + const links = require('../model/links/links.model') + + fleetOf({ a: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, true) + assert.equal(result.already, true) +}) + +test('a link is never shaped with anything a code could be recovered from', async () => { + withCore() + const links = require('../model/links/links.model') + + const shaped = links.shape({ + steamId: '7656', + userId: 4, + username: 'someone', + name: 'Wanderer', + serverId: 'a', + linkedAt: '2026-09-21T00:00:00Z', + }) + + // `userId` and `username` are deliberately absent: the caller is the user, and + // a list that carried somebody's website username would be a different fact + // from "you hold this Steam id". + assert.deepEqual(Object.keys(shaped).sort(), ['linkedAt', 'name', 'serverId', 'steamId']) +}) + +test('an unlink is scoped by user in the statement, not checked before it', async () => { + const { queries } = withCore() + const links = require('../model/links/links.model') + + await links.unlinkOwned('7656', 4) + + const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE')) + + // Read-then-write would leave a gap between the ownership test and the + // deletion; one statement closes it, and the row count is what tells "removed" + // from "was not yours". + assert.ok(del.sql.includes('user_id = ?')) + assert.deepEqual(del.params, ['7656', 4]) +}) + +test('the in-game unlink is scoped by Steam id alone, because that is the authority', async () => { + const { queries } = withCore() + const links = require('../model/links/links.model') + + await links.unlinkFromGame('7656') + + const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE')) + + // Whoever is connected to the game as that Steam account is who it is — a + // stronger proof of ownership than the site can obtain any other way. Scoping + // this by website user would make `/unlink` fail for the one player who needs + // it: the one who linked the wrong account. + assert.ok(!del.sql.includes('user_id')) + assert.deepEqual(del.params, ['7656']) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index 096ae61..8b797bb 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -148,6 +148,290 @@ } } }, + "/api/v1/admin/users/{id}/rust/links": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s linked Steam accounts and their Rust record (admin only)", + "description": "Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustAdminLinkList" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/rust/links/{steamId}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Sever a user’s Steam link (admin only)", + "description": "Staff release a link on this user’s behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "steamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Steam id to release." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "unlinked": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "404": { + "description": "Not linked to this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/rust/link": { + "post": { + "tags": [ + "Player · Rust" + ], + "summary": "Link a Steam account with a one-time code from /link in game", + "description": "The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "That Steam account is linked to another website account", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many link attempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "A server could not be reached — the code is still good", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustLinkRequest" + } + } + } + } + } + }, + "/api/v1/player/rust/links": { + "get": { + "tags": [ + "Player · Rust" + ], + "summary": "The Steam accounts the caller has linked", + "description": "Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustLinkList" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/rust/links/{steamId}": { + "delete": { + "tags": [ + "Player · Rust" + ], + "summary": "Release a Steam account the caller has linked", + "description": "Removes the caller’s own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.", + "parameters": [ + { + "name": "steamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Steam id to release." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "unlinked": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "404": { + "description": "Not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/player/rust/servers": { "get": { "tags": [ @@ -854,6 +1138,517 @@ } } }, + "RustLink": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One Steam account linked to a website user. Never carries a code." + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "What the player was called in game when they linked. A display name only — a Rust name changes on a whim and nothing identifies anybody by it." + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts." + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "RustLinkList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The Steam accounts one website user holds (GET /player/rust/links)." + }, + "properties": { + "type": "object", + "properties": { + "links": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustLink" + } + } + } + } + } + } + }, + "RustLinkRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "code" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The six-character code /link handed the player in game. Good for five minutes, and it works once." + }, + "example": { + "type": "string", + "example": "K7M2PQ" + } + } + } + } + } + } + }, + "RustLinkResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The result of redeeming a code." + }, + "properties": { + "type": "object", + "properties": { + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "link": { + "$ref": "#/components/schemas/RustLink" + }, + "already": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True when this Steam id was already linked to the caller — a second press of the button, not an error." + }, + "example": { + "type": "boolean", + "example": false + } + } + } + } + } + } + }, + "RustAdminLinkList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One user’s Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links)." + }, + "properties": { + "type": "object", + "properties": { + "links": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "What the game last saw this player called, falling back to the name recorded at link time." + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "linkedName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "firstSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "All-time totals per server, summed across every wipe." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "serverName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Main · Vanilla" + } + } + }, + "kills": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 41 + } + } + }, + "deaths": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 37 + } + } + }, + "npcKills": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 120 + } + } + }, + "structures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 64 + } + } + }, + "playtimeSec": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 43200 + } + } + }, + "wipes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "lastSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, "RustSidecarProbe": { "type": "object", "properties": { -- 2.49.1 From 0a1e558942dd2e1eee56131bf1ef20de3156c821 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 08:19:41 -0500 Subject: [PATCH 2/2] test(rust): grow the mount check for the slot it predicted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the manifest and the module's declared mounts agree` was written in phase 1 with its own exception named in a comment: when `admin.users.detail` arrives, its routes live on a resource core owns and the test must grow the exception deliberately rather than let a route outside every declared mount arrive unnoticed. This is that growth, and the test did its job — it failed on the first run after the slot was filled. A route is now legitimate if it is under a declared prefix OR under the mount of a slot `module.json` declares, and a declared slot that contributes no route fails too: core never checks that a declared slot was filled (`checkDeclared` covers `mounts` alone), so this is the only place an exception widening the check for nothing is noticed. Verified by pointing the slot mount at a path nothing serves and watching it fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- server/test/frozenManifest.test.js | 49 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/server/test/frozenManifest.test.js b/server/test/frozenManifest.test.js index 39da6e7..bb85514 100644 --- a/server/test/frozenManifest.test.js +++ b/server/test/frozenManifest.test.js @@ -157,21 +157,50 @@ test('every path in the fragment is fully qualified', () => { test("the manifest and the module's declared mounts agree", () => { const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')) - const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8')) + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8')) const declared = [] - for (const [tier, prefixes] of Object.entries(mounts)) { + for (const [tier, prefixes] of Object.entries(manifest.mounts)) { for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`) } - // Every route this module serves is under a prefix it declared. There is no - // exception here yet, and that is the point of asserting it now: phase 6 adds - // the `admin.users.detail` extension slot, whose routes live under core's - // `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that - // arrives this test must grow the exception deliberately, rather than a route - // outside every declared mount arriving unnoticed. + // **The exception this test predicted, now grown deliberately.** Phase 6 fills + // `admin.users.detail`, whose routes live on a resource CORE owns + // (`/api/v1/admin/users/:id`) rather than under any mount of ours — §2.4's + // fourth mount shape. So a route is legitimate if it is under a declared + // prefix, or under the mount of a slot this module declares. + // + // The slot's mount is restated here rather than imported, for the same reason + // the protocol catalogue is restated in `catalogue.test.js`: it is CORE's + // constant, and a module that derived it from its own generator would be + // checking that file against itself. + const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/' } + + const slots = (manifest.extensions || []).map((slot) => { + const mount = SLOT_MOUNT[slot] + assert.ok(mount, `module.json declares slot "${slot}", which §2.4's table does not list`) + return { slot, mount } + }) + + const used = new Set() + for (const route of routes) { - const under = declared.some((d) => route.path.startsWith(d)) - assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`) + if (declared.some((d) => route.path.startsWith(d))) continue + + const slot = slots.find((s) => route.path.startsWith(s.mount)) + assert.ok( + slot, + `${route.method} ${route.path} is served from outside every mount module.json declares, ` + + 'and outside every slot it fills', + ) + used.add(slot.slot) + } + + // The other half, and the reason the exception is narrow: a declared slot that + // contributes no route is an exception widening this check for nothing. Core + // never checks that a declared slot was filled (`checkDeclared` covers `mounts` + // alone), so this is the only place it is noticed. + for (const { slot } of slots) { + assert.ok(used.has(slot), `module.json declares "${slot}" but no route in the manifest comes from it`) } }) -- 2.49.1