feat(auth): sign in with Steam (MODULE_API 1.17.0)

Steam is a built-in provider beside Google and Discord (STEAM_SIGNIN.md,
D392-D396). OpenID 2.0: the assertion is verified server to server with
check_authentication, return_to must match byte for byte, and the claimed id
must be a 17-digit SteamID64. A Steam Web API key is required to enable it
(D394); persona name and avatar are stored on the identity and refreshed at
each sign-in, and a failed lookup never blocks one. Steam never signs anybody
up (D395), on the web or the mobile bridge. Several Steam accounts may be
linked; DELETE /auth/me/account/identities/:provider takes ?id= to unlink one.

MODULE_API 1.17.0: ctx.identities.list, api.onIdentityLinking (refuse-only,
fails closed, module text never shown) and api.onIdentityLinked. No unlink
hook (D393). Schema additive; OpenAPI, routes and engagement manifests
regenerated with no modules installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-19 14:02:49 -05:00
parent 6ba2ed90ed
commit 5e398d8acd
30 changed files with 1346 additions and 126 deletions

View File

@@ -311,6 +311,26 @@ function buildCtx(id, moduleRoot) {
// exports creation, role changes and password handling, none of which is a
// module's business.
users: { getById: users.getById },
// API 1.17.0 (STEAM_SIGNIN.md §4). READ only, one provider at a time: what a
// user has linked, as core verified it — the Steam id a module can trust is
// the one Steam's own `check_authentication` proved, never one a player typed.
// Linking and unlinking stay core's (the hooks on `api` are how a module takes
// part), and the row id and email are core's business, so they are not here.
identities: {
list: async (userId, provider) => {
// eslint-disable-next-line global-require
const rows = await require('../model/userIdentities/userIdentities.model').listForUser(userId)
return rows
.filter((r) => r.provider === provider)
.map((r) => ({
provider: r.provider,
subject: r.subject,
displayName: r.display_name || null,
avatarUrl: r.avatar_url || null,
linkedAt: r.created_at,
}))
},
},
// Where this deployment is reachable, for a module that has to build an
// absolute link (an announcement carries one into a game window or a chat
// message, where a relative path means nothing). §2.7 forbids a module
@@ -472,6 +492,17 @@ function buildApi(record) {
// never calls them.
onBoot: hook('onBoot'),
onShutdown: hook('onShutdown'),
// API 1.17.0 (STEAM_SIGNIN.md §4). Core calling the module about an external
// identity a signed-in user is linking, dispatched from auth/identityHooks.js.
//
// `onIdentityLinking` answers before anything is written and can only REFUSE
// (D396: a Steam id already another account's Rust link). It runs inside the
// link flow of a user who is already signed in, so it has no path to a session
// and cannot let through a link core would refuse. `onIdentityLinked` hears a
// link after it is written. There is no unlink hook: D393 leaves a module
// nothing to undo when a sign-in identity goes.
onIdentityLinking: hook('onIdentityLinking'),
onIdentityLinked: hook('onIdentityLinked'),
}
}
@@ -794,7 +825,7 @@ function load(tierRouters) {
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null },
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
client: null,
ctx: null,
state: 'installed',
@@ -1022,6 +1053,24 @@ function shutdownHooks() {
.reverse()
}
/**
* The identity hooks of one name (`onIdentityLinking` | `onIdentityLinked`), in
* registration order (API 1.17.0).
*
* `started` only, the rule every dispatch here follows: a module that is
* disabled, or whose `onBoot` threw, is not serving and has no say in a link.
* Empty in a process that never loaded modules (the seed script, unit tests), so
* the link flow there behaves exactly as a bare core.
*
* @returns {{id: string, hook: Function}[]}
*/
function identityHooks(name) {
if (!loaded) return []
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks[name])
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* One module's `onShutdown`, for stopping it on its own rather than at exit.
*
@@ -1147,6 +1196,7 @@ module.exports = {
fragments,
bootable,
shutdownHooks,
identityHooks,
stopHook,
clientChunks,
clientEntryUrls,