chore(release): cut edge over to main — Steam sign-in (MODULE_API 1.17.0) and the last fixes' core half #220
@@ -133,8 +133,12 @@ export const api = {
|
||||
totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
// Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link.
|
||||
myIdentities: () => req('/auth/me/account/identities'),
|
||||
unlinkIdentity: (provider) =>
|
||||
req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }),
|
||||
// With an identity id, only that one goes (a person may hold several Steam accounts).
|
||||
unlinkIdentity: (provider, id) =>
|
||||
req(
|
||||
`/auth/me/account/identities/${encodeURIComponent(provider)}${id != null ? `?id=${encodeURIComponent(id)}` : ''}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
|
||||
// returned ONCE (password step-up for accounts that have a password).
|
||||
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Inline SVG brand icons for SSO providers. No binary assets — these scale
|
||||
// crisply at any size and keep their own brand colors. `icon` matches the
|
||||
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
|
||||
// provider `kind` from the discovery endpoint ('google' | 'discord' | 'steam' | oidc/oauth2).
|
||||
// Anything unknown falls back to a neutral key glyph in the current text color.
|
||||
|
||||
function GoogleMark({ size }) {
|
||||
@@ -22,6 +22,16 @@ function DiscordMark({ size }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Steam's mark in the current text color: its brand ink is near-black, which
|
||||
// would vanish on the dark theme.
|
||||
function SteamMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
@@ -33,5 +43,6 @@ function GenericMark({ size }) {
|
||||
export default function ProviderIcon({ icon, size = 18 }) {
|
||||
if (icon === 'google') return <GoogleMark size={size} />
|
||||
if (icon === 'discord') return <DiscordMark size={size} />
|
||||
if (icon === 'steam') return <SteamMark size={size} />
|
||||
return <GenericMark size={size} />
|
||||
}
|
||||
|
||||
66
client/src/components/security/linkedIdentities.jsx
Normal file
66
client/src/components/security/linkedIdentities.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import ProviderIcon from '../ProviderIcon.jsx'
|
||||
|
||||
// What the player's and the staff account pages share about a linked sign-in
|
||||
// identity. Steam is the one provider that differs (STEAM_SIGNIN.md): a person may
|
||||
// link several Steam accounts, each shows its persona name, avatar and SteamID64,
|
||||
// and unlinking one removes only the sign-in — a game link made from it stays
|
||||
// (D393).
|
||||
|
||||
// Providers a person may link more than one account of.
|
||||
const MULTI_LINK = new Set(['steam'])
|
||||
|
||||
export function canLinkAgain(providerId) {
|
||||
return MULTI_LINK.has(providerId)
|
||||
}
|
||||
|
||||
export function identityIcon(provider) {
|
||||
return provider === 'google' || provider === 'discord' || provider === 'steam' ? provider : 'oidc'
|
||||
}
|
||||
|
||||
// The outcome of a link round trip, from the query string it returns with.
|
||||
export function linkBanner(search) {
|
||||
const q = new URLSearchParams(search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user on this site.' }
|
||||
if (q.get('link_error') === 'refused') return { ok: false, text: 'That account cannot be linked right now. Please try again later.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
}
|
||||
|
||||
// Providers to offer a Link button for: every enabled one not yet linked, and a
|
||||
// multi-link one (Steam) always.
|
||||
export function linkableProviders(available, linked) {
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
return available.filter((p) => !linkedIds.has(p.id) || canLinkAgain(p.id))
|
||||
}
|
||||
|
||||
export function unlinkPrompt(identity, providerName) {
|
||||
if (identity.provider === 'steam') {
|
||||
const who = identity.display_name ? `${identity.display_name} (${identity.subject})` : identity.subject
|
||||
return `Unlink the Steam account ${who}? You will no longer be able to sign in with it. A game link made with it stays until you remove it on that game's page.`
|
||||
}
|
||||
return `Unlink ${providerName} from your account?`
|
||||
}
|
||||
|
||||
// The icon (or Steam avatar), the provider name, and what identifies the account.
|
||||
export function IdentitySummary({ identity, providerName }) {
|
||||
const isSteam = identity.provider === 'steam'
|
||||
return (
|
||||
<>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20, color: 'var(--head)', flex: '0 0 auto' }}>
|
||||
{isSteam && identity.avatar_url ? (
|
||||
<img src={identity.avatar_url} alt="" width={20} height={20} style={{ borderRadius: 4 }} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<ProviderIcon icon={identityIcon(identity.provider)} size={20} />
|
||||
)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem', overflowWrap: 'anywhere' }}>
|
||||
{isSteam && identity.display_name ? `${providerName}: ${identity.display_name}` : providerName}
|
||||
</div>
|
||||
{isSteam && identity.subject && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.subject}</div>}
|
||||
{!isSteam && identity.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.email}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
|
||||
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
|
||||
// Server-side; the client half is core's own account pages showing Steam. This
|
||||
// file bumps for the reason at the top.
|
||||
// 1.16.0 — a dry run's `detail.warnings` (Rust PLAN_REDESIGNS §11.4, D391): a
|
||||
// module's advice, shown by core's own event editor as warning findings.
|
||||
// Server-side; this file bumps for the reason at the top.
|
||||
@@ -94,4 +98,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.16.0'
|
||||
export const MODULE_API_VERSION = '1.17.0'
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
|
||||
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
@@ -15,13 +16,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -40,12 +35,11 @@ function LinkedAccounts() {
|
||||
}, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -55,8 +49,7 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
@@ -64,7 +57,7 @@ function LinkedAccounts() {
|
||||
Linked accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
Link a Google, Discord, Steam, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
@@ -77,15 +70,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,14 @@ import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin config for authentication providers. Local password + TOTP is always on
|
||||
// (informational tab). Google/Discord are built-ins with a fixed config surface
|
||||
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor.
|
||||
// (Enabled + Client ID + Client Secret); Steam is a built-in with Enabled + a
|
||||
// Steam Web API key. Custom providers use the full OIDC editor.
|
||||
|
||||
const TABS = [
|
||||
{ id: 'local', label: 'Local Accounts' },
|
||||
{ id: 'google', label: 'Google' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
{ id: 'steam', label: 'Steam' },
|
||||
{ id: 'custom', label: 'Custom Providers' },
|
||||
]
|
||||
|
||||
@@ -141,6 +143,87 @@ function BuiltinForm({ provider, onSaved }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Steam (OpenID 2.0) ─────────────────────────────────────────────────────
|
||||
// No client id and no redirect URL to register: Steam's sign-in takes neither.
|
||||
// What it needs is a Steam Web API key, without which it cannot be enabled
|
||||
// (STEAM_SIGNIN.md D394) — the key is how a linked account shows its persona
|
||||
// name and avatar. Steam never creates an account (D395): a player links Steam
|
||||
// to the account they already have.
|
||||
function SteamForm({ provider, onSaved }) {
|
||||
const [enabled, setEnabled] = useState(provider.enabled)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const body = { enabled }
|
||||
if (apiKey) body.secret = apiKey // only send a new key when entered
|
||||
await api.admin.updateAuthProvider(provider.id, body)
|
||||
setApiKey('')
|
||||
setMsg('Saved.')
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ display: 'inline-flex', width: 26, height: 26, color: 'var(--head)' }}>
|
||||
<ProviderIcon icon="steam" size={26} />
|
||||
</span>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Players link their Steam account from their account page and can then sign in with it. Steam never
|
||||
creates an account: an unlinked Steam account signs nobody in, whatever the registration setting.
|
||||
</p>
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
|
||||
<HealthWarning provider={provider} />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Steam Web API key (required)</span>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Steam Web API key'}
|
||||
/>
|
||||
</label>
|
||||
<p className="sans dim" style={{ margin: '-8px 0 0', fontSize: '0.82rem', lineHeight: 1.5 }}>
|
||||
Get one at{' '}
|
||||
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer" className="link-accent">
|
||||
steamcommunity.com/dev/apikey
|
||||
</a>{' '}
|
||||
with this site's domain. It is used only to show a linked account's Steam name and avatar, and is
|
||||
never sent to a browser. No redirect URL needs registering with Steam.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Local accounts (informational) ─────────────────────────────────────────
|
||||
function LocalInfo() {
|
||||
return (
|
||||
@@ -376,6 +459,7 @@ export default function AuthProvidersAdmin() {
|
||||
{tab === 'local' && <LocalInfo />}
|
||||
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
|
||||
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
|
||||
{tab === 'steam' && byId('steam') && <SteamForm key="steam" provider={byId('steam')} onSaved={load} />}
|
||||
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1497,11 +1497,16 @@ export default function EventEditor() {
|
||||
setEvent(result.event)
|
||||
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
|
||||
// The re-pin count is said out loud, because an editor who does not know
|
||||
// their fix reached next Friday finds out on Friday.
|
||||
// their fix reached next Friday finds out on Friday. So is a dry run that
|
||||
// carried over from the draft (W5): it is why no second one is asked for.
|
||||
setNotice(
|
||||
result.repinned
|
||||
? `Published as v${result.version}. ${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
|
||||
: `Published as v${result.version}.`,
|
||||
[
|
||||
`Published as v${result.version}.`,
|
||||
result.verified ? 'The dry run that passed on this draft counts for it.' : '',
|
||||
result.repinned
|
||||
? `${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
|
||||
: '',
|
||||
].filter(Boolean).join(' '),
|
||||
)
|
||||
} catch (err) {
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
@@ -1674,6 +1679,7 @@ export default function EventEditor() {
|
||||
report that did not say would be read as the other one. */}
|
||||
{report.target === 'version' ? `published v${report.version}` : 'the working draft'}
|
||||
{report.recorded && ' · recorded, so scheduled occurrences may now start'}
|
||||
{report.draftRecorded && ' · kept, so publishing this draft unchanged makes a verified version'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -2019,7 +2025,7 @@ export default function EventEditor() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||
The key is what the run console groups by and what “phase 3 has not started” names, so it
|
||||
The key is what the run console groups by and what “Phase p3: steps done, waiting to advance” names, so it
|
||||
cannot change once runs exist.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -132,7 +132,9 @@ function GateRow({ gate, current }) {
|
||||
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
|
||||
Phase <strong>{gate.phase}</strong>
|
||||
{current && !gate.satisfied ? ' has not started' : ''}
|
||||
{/* W2: the phase is running and its steps are done; it is the advance
|
||||
that waits, so "has not started" was the wrong sentence. */}
|
||||
{current && !gate.satisfied ? ': steps done, waiting to advance' : ''}
|
||||
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
|
||||
{gate.stalled && ' — STALLED'}
|
||||
</div>
|
||||
@@ -389,7 +391,7 @@ export default function EventRun() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Why this phase has not started (Phase 5) ──
|
||||
{/* ── Why this phase has not advanced (Phase 5) ──
|
||||
Above the step list for the same reason the parked cue is: a phase
|
||||
waiting on a condition is `running` and looks completely healthy, and
|
||||
the one screen an operator opens to find out why nothing is happening
|
||||
@@ -400,7 +402,7 @@ export default function EventRun() {
|
||||
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
|
||||
>
|
||||
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
|
||||
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
|
||||
{waiting ? 'Why this phase has not advanced' : 'Phase advance conditions'}
|
||||
</h3>
|
||||
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
|
||||
{waiting ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
|
||||
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
|
||||
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
|
||||
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
|
||||
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
@@ -224,13 +225,7 @@ function LinkedAccounts() {
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
const banner = linkBanner(window.location.search)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -247,12 +242,11 @@ function LinkedAccounts() {
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
async function unlink(identity) {
|
||||
if (!window.confirm(unlinkPrompt(identity, nameFor(identity.provider)))) return
|
||||
try {
|
||||
await api.unlinkIdentity(provider)
|
||||
await api.unlinkIdentity(identity.provider, identity.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
@@ -262,13 +256,12 @@ function LinkedAccounts() {
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
const linkable = linkableProviders(available, linked)
|
||||
|
||||
return (
|
||||
<Section title="Linked accounts">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other provider so you can sign in with it.
|
||||
Link a Google, Discord, Steam, or other provider so you can sign in with it.
|
||||
</p>
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||
@@ -276,15 +269,9 @@ function LinkedAccounts() {
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
<div key={i.id ?? i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<IdentitySummary identity={i} providerName={nameFor(i.provider)} />
|
||||
<button onClick={() => unlink(i)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS activity_log (
|
||||
-- custom (oidc/oauth2) providers.
|
||||
CREATE TABLE IF NOT EXISTS auth_providers (
|
||||
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
|
||||
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
|
||||
kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
client_id VARCHAR(255) NULL,
|
||||
@@ -203,6 +203,8 @@ CREATE TABLE IF NOT EXISTS user_identities (
|
||||
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
|
||||
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
|
||||
email VARCHAR(255) NULL,
|
||||
display_name VARCHAR(191) NULL, -- Steam persona name (D394); null for other providers
|
||||
avatar_url VARCHAR(500) NULL, -- Steam avatar, a Steam CDN https URL; null for others
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_identity_provider_subject (provider, subject),
|
||||
@@ -2606,6 +2608,18 @@ ALTER TABLE event_run_resources MODIFY COLUMN status
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
|
||||
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
|
||||
|
||||
-- A dry run that passed on the DRAFT, and the fingerprint of the spec it passed
|
||||
-- on (W5, Rust PLAN_LAST_FIXES). A draft changes under the author's hands, so a
|
||||
-- pass on it is not a property of any version -- but publish compares this
|
||||
-- fingerprint with the spec it is about to freeze, and when they are equal the
|
||||
-- new version is stamped verified with this pass's reviewer and time. Without it
|
||||
-- an author who dry-ran and then published found v1 "not dry run", and its
|
||||
-- scheduled and trigger starts held, until they ran the same check again. An
|
||||
-- edit after the dry run changes the fingerprint, so nothing stale carries.
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_at DATETIME NULL;
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_by INT NULL;
|
||||
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS draft_verified_hash CHAR(64) NULL;
|
||||
|
||||
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
|
||||
--
|
||||
-- **Not a second answer to the question `state` answers**, which is the trap the
|
||||
@@ -2744,3 +2758,12 @@ CREATE TABLE IF NOT EXISTS event_trigger_starts (
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Sign in with Steam (STEAM_SIGNIN.md §4). Steam is a built-in provider beside
|
||||
-- Google and Discord, so its row needs the kind; and a linked Steam account
|
||||
-- shows its persona name and avatar (D394), kept on the identity and refreshed
|
||||
-- at each Steam sign-in. Both additive. MODIFY has no IF NOT EXISTS form, but
|
||||
-- re-declaring the same ENUM is an idempotent no-op.
|
||||
ALTER TABLE auth_providers MODIFY COLUMN kind ENUM('google','discord','oidc','oauth2','steam') NOT NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS display_name VARCHAR(191) NULL;
|
||||
ALTER TABLE user_identities ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500) NULL;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
|
||||
"moduleApiVersion": "1.16.0",
|
||||
"moduleApiVersion": "1.17.0",
|
||||
"triggers": [
|
||||
{
|
||||
"id": "event.phase.changed",
|
||||
|
||||
@@ -1834,7 +1834,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/auth/me/account/identities/:provider",
|
||||
"handlers": 3,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
|
||||
97
server/src/auth/identityHooks.js
Normal file
97
server/src/auth/identityHooks.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// ── Modules and identity links (MODULE_API 1.17.0) ─────────────────────────
|
||||
//
|
||||
// Core owns linking an external sign-in identity to an account. Modules take part
|
||||
// in exactly two places (STEAM_SIGNIN.md §4):
|
||||
//
|
||||
// checkLink() before the identity is written. Each module that registered
|
||||
// `onIdentityLinking` may refuse. Rust uses it for D396: a Steam
|
||||
// id that is already another website account's Rust link.
|
||||
// notifyLinked() after it is written. Rust uses it for D392: the Steam account
|
||||
// becomes a linked Rust player.
|
||||
//
|
||||
// A refusal can only stop a link. It runs inside the link flow of a user who is
|
||||
// already signed in, so nothing here can mint a session or let through a link
|
||||
// core would refuse.
|
||||
//
|
||||
// What a module says is never shown to the player: `reason: 'in_use'` reads as
|
||||
// the same "already linked to another user" core says itself, and anything else
|
||||
// as a plain refusal. A module's own words go to the log. Putting module text in
|
||||
// a redirect URL would let anybody craft a link that prints a sentence of their
|
||||
// choosing on our account page.
|
||||
|
||||
const loader = require('../modules/loader')
|
||||
const log = require('../utils/logger')('sso')
|
||||
|
||||
const HOOK_TIMEOUT_MS = 5_000
|
||||
|
||||
function withTimeout(fn, ms) {
|
||||
let timer
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask every started module whether this link may be made.
|
||||
*
|
||||
* **Fails closed.** A module that throws or does not answer in time refuses the
|
||||
* link: the hook exists to stop a link that must not happen, and a module that
|
||||
* could not look is a module that could not say it was safe. The player is told
|
||||
* to try again; a link is never the urgent half of anything.
|
||||
*
|
||||
* @param {{userId: number, provider: string, subject: string}} link
|
||||
* @returns {Promise<{ok: true} | {ok: false, reason: 'in_use' | 'refused', module: string}>}
|
||||
*/
|
||||
async function checkLink(link) {
|
||||
const frozen = Object.freeze({ userId: link.userId, provider: link.provider, subject: link.subject })
|
||||
for (const { id, hook } of loader.identityHooks('onIdentityLinking')) {
|
||||
let answer
|
||||
try {
|
||||
answer = await withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS)
|
||||
} catch (err) {
|
||||
log.error('identity link check failed; refusing the link', { module: id, provider: frozen.provider, message: err.message })
|
||||
return { ok: false, reason: 'refused', module: id }
|
||||
}
|
||||
if (answer && answer.ok === false) {
|
||||
const reason = answer.reason === 'in_use' ? 'in_use' : 'refused'
|
||||
log.info('identity link refused by a module', {
|
||||
module: id,
|
||||
provider: frozen.provider,
|
||||
userId: frozen.userId,
|
||||
reason,
|
||||
message: typeof answer.message === 'string' ? answer.message.slice(0, 200) : undefined,
|
||||
})
|
||||
return { ok: false, reason, module: id }
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell every started module a link was written.
|
||||
*
|
||||
* Awaited (bounded) so the page the player lands on already shows what a module
|
||||
* made of it — the Rust page listing the Steam account they just linked — but a
|
||||
* failure never reaches the link: the identity is written, and that is core's
|
||||
* answer. A module that missed it is logged.
|
||||
*
|
||||
* @param {{userId: number, provider: string, subject: string, displayName?: string|null}} link
|
||||
*/
|
||||
async function notifyLinked(link) {
|
||||
const frozen = Object.freeze({
|
||||
userId: link.userId,
|
||||
provider: link.provider,
|
||||
subject: link.subject,
|
||||
displayName: link.displayName || null,
|
||||
})
|
||||
await Promise.all(
|
||||
loader.identityHooks('onIdentityLinked').map(({ id, hook }) =>
|
||||
withTimeout(() => hook(frozen), HOOK_TIMEOUT_MS).catch((err) => {
|
||||
log.error('identity linked hook failed', { module: id, provider: frozen.provider, message: err.message })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { checkLink, notifyLinked, HOOK_TIMEOUT_MS }
|
||||
@@ -12,6 +12,7 @@
|
||||
const GoogleProvider = require('./google.provider')
|
||||
const DiscordProvider = require('./discord.provider')
|
||||
const GenericOidcProvider = require('./genericOidc.provider')
|
||||
const SteamProvider = require('./steam.provider')
|
||||
const authProviders = require('../../model/authProviders/authProviders.model')
|
||||
|
||||
// kind → provider class.
|
||||
@@ -20,6 +21,7 @@ const KINDS = {
|
||||
discord: DiscordProvider,
|
||||
oidc: GenericOidcProvider,
|
||||
oauth2: GenericOidcProvider,
|
||||
steam: SteamProvider,
|
||||
}
|
||||
|
||||
// Built-in providers and their fixed display metadata. Endpoints are in the
|
||||
@@ -27,6 +29,9 @@ const KINDS = {
|
||||
const BUILTINS = [
|
||||
{ id: 'google', kind: 'google', name: 'Google', priority: 1 },
|
||||
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
|
||||
// OpenID 2.0, not OAuth2 (STEAM_SIGNIN.md §3): no client id, and the "secret"
|
||||
// is the Steam Web API key it cannot be enabled without (D394).
|
||||
{ id: 'steam', kind: 'steam', name: 'Steam', priority: 3 },
|
||||
]
|
||||
|
||||
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
|
||||
@@ -35,6 +40,18 @@ function isBuiltin(id) {
|
||||
return BUILTIN_IDS.has(id)
|
||||
}
|
||||
|
||||
// Providers whose callback is OpenID 2.0 (`openid.*` fields) rather than an OAuth2
|
||||
// `code`. Steam is the only one, and only as the built-in: a custom provider can
|
||||
// only be oidc/oauth2, so the kind is known from the id before any row is read.
|
||||
function isOpenId(id) {
|
||||
return id === 'steam'
|
||||
}
|
||||
|
||||
// Providers that never sign anybody up, whatever player_registration says (D395).
|
||||
function neverProvisions(kind) {
|
||||
return kind === 'steam'
|
||||
}
|
||||
|
||||
// Instantiate a provider from a config row (secret already decrypted by the
|
||||
// model as `client_secret`). Returns null for an unknown kind.
|
||||
function instantiate(row) {
|
||||
@@ -66,6 +83,11 @@ async function load(id) {
|
||||
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
|
||||
function validateConfig(row) {
|
||||
const missing = []
|
||||
if (row.kind === 'steam') {
|
||||
// No client id; the Web API key is the one thing it needs (D394).
|
||||
if (!row.client_secret_enc && !row.client_secret) missing.push('api_key')
|
||||
return { valid: missing.length === 0, missing }
|
||||
}
|
||||
if (!row.client_id) missing.push('client_id')
|
||||
// A stored secret shows up as client_secret_enc on plain rows, or client_secret
|
||||
// on decrypted rows — accept either as "has a secret".
|
||||
@@ -110,7 +132,7 @@ async function listEnabledValid() {
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2'
|
||||
icon: p.kind, // 'google' | 'discord' | 'steam' | 'oidc' | 'oauth2'
|
||||
loginUrl: `/api/v1/auth/sso/${p.id}/start`,
|
||||
priority: p.priority ?? 100,
|
||||
}))
|
||||
@@ -120,6 +142,8 @@ module.exports = {
|
||||
KINDS,
|
||||
BUILTINS,
|
||||
isBuiltin,
|
||||
isOpenId,
|
||||
neverProvisions,
|
||||
instantiate,
|
||||
load,
|
||||
validateConfig,
|
||||
|
||||
172
server/src/auth/providers/steam.provider.js
Normal file
172
server/src/auth/providers/steam.provider.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// Built-in Steam provider (OpenID 2.0, STEAM_SIGNIN.md §3).
|
||||
//
|
||||
// Steam is not OAuth2: there is no client id, no token endpoint and no `state`
|
||||
// parameter. The browser goes to steamcommunity.com with an OpenID 2.0
|
||||
// `checkid_setup` request and comes back carrying `openid.*` fields, and the ONLY
|
||||
// proof those fields are genuine is asking Steam itself — a server-to-server
|
||||
// `check_authentication` POST of the same fields, which must answer
|
||||
// `is_valid:true`. Our CSRF `state` rides inside `return_to`, where the existing
|
||||
// single-use tx cookie verifies it exactly as it does for the OAuth providers.
|
||||
//
|
||||
// The admin-configured "secret" is a Steam Web API key (D394), used for one thing:
|
||||
// the persona name and avatar shown beside a linked Steam account. That lookup
|
||||
// decorates an identity and never decides a sign-in — if it fails, the sign-in
|
||||
// goes ahead with the bare id.
|
||||
|
||||
const BaseProvider = require('./base.provider')
|
||||
const log = require('../../utils/logger')('sso')
|
||||
|
||||
const OP_ENDPOINT = 'https://steamcommunity.com/openid/login'
|
||||
const OPENID_NS = 'http://specs.openid.net/auth/2.0'
|
||||
const IDENTIFIER_SELECT = 'http://specs.openid.net/auth/2.0/identifier_select'
|
||||
const CLAIMED_ID_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/
|
||||
const SUMMARIES_URL = 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/'
|
||||
// Steam serves avatars from its own CDNs. Anything else in that field is not
|
||||
// something we render.
|
||||
const AVATAR_RE = /^https:\/\/([a-z0-9-]+\.)*(steamstatic\.com|akamaihd\.net)\/[^\s"'<>]{1,400}$/i
|
||||
|
||||
const VERIFY_TIMEOUT_MS = 10_000
|
||||
const SUMMARY_TIMEOUT_MS = 5_000
|
||||
|
||||
// A failure the controller turns into a sentence rather than a 500.
|
||||
class SteamAssertionError extends Error {
|
||||
constructor(reason) {
|
||||
super(`steam assertion refused: ${reason}`)
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// The 17-digit SteamID64 at the end of a claimed id, or null.
|
||||
function parseClaimedId(claimedId) {
|
||||
const m = typeof claimedId === 'string' ? CLAIMED_ID_RE.exec(claimedId) : null
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// The `return_to` we send, and must get back byte for byte: the callback URL with
|
||||
// our tx nonce as `state`.
|
||||
function returnToFor(redirectUri, state) {
|
||||
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||
return `${redirectUri}${sep}state=${encodeURIComponent(state)}`
|
||||
}
|
||||
|
||||
// The realm is the origin the return_to lives under — what Steam shows the user
|
||||
// as "you are signing in to".
|
||||
function realmFor(redirectUri) {
|
||||
return new URL(redirectUri).origin
|
||||
}
|
||||
|
||||
// Every `openid.*` field from a parsed query, as strings. Anything else is ours
|
||||
// (`state`) and does not go back to Steam.
|
||||
function openidFields(query = {}) {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (k.startsWith('openid.') && typeof v === 'string') out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
class SteamProvider extends BaseProvider {
|
||||
constructor(config = {}) {
|
||||
super({ kind: 'steam', name: 'Steam', ...config, id: config.id || 'steam' })
|
||||
// Stored in client_secret_enc like any secret; it is a Web API key here.
|
||||
this.apiKey = config.clientSecret ?? config.client_secret ?? null
|
||||
}
|
||||
|
||||
getAuthorizationUrl(state, { redirectUri } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': OPENID_NS,
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnToFor(redirectUri, state),
|
||||
'openid.realm': realmFor(redirectUri),
|
||||
'openid.identity': IDENTIFIER_SELECT,
|
||||
'openid.claimed_id': IDENTIFIER_SELECT,
|
||||
})
|
||||
return `${OP_ENDPOINT}?${params.toString()}`
|
||||
}
|
||||
|
||||
// `query` is the callback's parsed query string; `redirectUri` and `state` are
|
||||
// what we sent, so `return_to` can be matched exactly. Resolves to the usual
|
||||
// { subject, email, emailVerified, name } plus displayName/avatarUrl, or throws
|
||||
// SteamAssertionError.
|
||||
async handleCallback({ query, redirectUri, state } = {}) {
|
||||
const fields = openidFields(query)
|
||||
if (fields['openid.mode'] !== 'id_res') throw new SteamAssertionError('mode')
|
||||
if (fields['openid.ns'] !== OPENID_NS) throw new SteamAssertionError('ns')
|
||||
if (fields['openid.op_endpoint'] !== OP_ENDPOINT) throw new SteamAssertionError('op_endpoint')
|
||||
if (fields['openid.return_to'] !== returnToFor(redirectUri, state)) throw new SteamAssertionError('return_to')
|
||||
const subject = parseClaimedId(fields['openid.claimed_id'])
|
||||
if (!subject) throw new SteamAssertionError('claimed_id')
|
||||
if (fields['openid.identity'] !== fields['openid.claimed_id']) throw new SteamAssertionError('identity')
|
||||
|
||||
if (!(await this.verifyAssertion(fields))) throw new SteamAssertionError('not_valid')
|
||||
|
||||
const summary = await this.playerSummary(subject)
|
||||
return {
|
||||
subject,
|
||||
email: null, // Steam never sends one
|
||||
emailVerified: false,
|
||||
name: summary.displayName,
|
||||
displayName: summary.displayName,
|
||||
avatarUrl: summary.avatarUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// The one proof (§5): send the same fields back with mode check_authentication.
|
||||
// Never skipped; any transport failure is a refusal, not a pass.
|
||||
async verifyAssertion(fields) {
|
||||
const body = new URLSearchParams({ ...fields, 'openid.mode': 'check_authentication' })
|
||||
let text
|
||||
try {
|
||||
const res = await fetch(OP_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) {
|
||||
log.warn('steam check_authentication failed', { status: res.status })
|
||||
return false
|
||||
}
|
||||
text = await res.text()
|
||||
} catch (err) {
|
||||
log.warn('steam check_authentication unreachable', { message: err.message })
|
||||
return false
|
||||
}
|
||||
return /^is_valid:true$/m.test(text)
|
||||
}
|
||||
|
||||
// Persona name + avatar through the Web API key. Decoration only: every failure
|
||||
// answers nulls and the sign-in carries on (D394). The key rides in the URL, so
|
||||
// the URL is never logged.
|
||||
async playerSummary(steamId) {
|
||||
const none = { displayName: null, avatarUrl: null }
|
||||
if (!this.apiKey) return none
|
||||
try {
|
||||
const url = `${SUMMARIES_URL}?key=${encodeURIComponent(this.apiKey)}&steamids=${steamId}`
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(SUMMARY_TIMEOUT_MS) })
|
||||
if (!res.ok) {
|
||||
log.warn('steam player summary failed; signing in with the bare id', { status: res.status })
|
||||
return none
|
||||
}
|
||||
const data = await res.json()
|
||||
const player = data && data.response && Array.isArray(data.response.players) ? data.response.players[0] : null
|
||||
if (!player || player.steamid !== steamId) return none
|
||||
const name = typeof player.personaname === 'string' ? player.personaname.trim().slice(0, 191) : ''
|
||||
const avatar = player.avatarfull || player.avatarmedium || player.avatar
|
||||
return {
|
||||
displayName: name || null,
|
||||
avatarUrl: typeof avatar === 'string' && AVATAR_RE.test(avatar) ? avatar : null,
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('steam player summary unreachable; signing in with the bare id', { message: err.message })
|
||||
return none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SteamProvider.SteamAssertionError = SteamAssertionError
|
||||
SteamProvider.parseClaimedId = parseClaimedId
|
||||
SteamProvider.returnToFor = returnToFor
|
||||
SteamProvider.OP_ENDPOINT = OP_ENDPOINT
|
||||
|
||||
module.exports = SteamProvider
|
||||
@@ -357,13 +357,16 @@ async function sweep() {
|
||||
* Reverting it would be core recording that it put something back when what
|
||||
* actually happened is that the thing vanished while nobody was looking, and the
|
||||
* two are different sentences to the operator reading the console afterwards.
|
||||
* One gone after its `until` is neither: the game ended it on time, so it is
|
||||
* `expired`, the line `expired()` would have written (W1).
|
||||
*
|
||||
* A module with no `reconcile()` on the action is not broken: core keeps
|
||||
* believing its own ledger, which is precisely the behaviour before this phase.
|
||||
*/
|
||||
async function reconcileModule(owner) {
|
||||
async function reconcileModule(owner, now = new Date()) {
|
||||
const rows = await resourcesDb.liveForModule(owner)
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
|
||||
const summary = { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 }
|
||||
const settled = new Set()
|
||||
if (!rows.length) return summary
|
||||
|
||||
const byStep = new Map()
|
||||
@@ -411,6 +414,19 @@ async function reconcileModule(owner) {
|
||||
summary.inForce += 1
|
||||
continue
|
||||
}
|
||||
// **Gone after its deadline is the deadline working** (W1, Rust
|
||||
// PLAN_LAST_FIXES). A resource that carries `until` is one the game ends
|
||||
// by itself, and on a reconnect the module's reconcile can run before the
|
||||
// game's backlog says so: the run log read `resource.orphaned` five seconds
|
||||
// before `resource.expired`. Past its `until`, gone is expired. Before it,
|
||||
// gone is still nobody-was-looking, which is what orphaned means.
|
||||
if (row.lease_until && new Date(row.lease_until).getTime() <= now.getTime()) {
|
||||
if (await settleExpired(owner, row)) {
|
||||
summary.expired += 1
|
||||
settled.add(row.run_id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
|
||||
summary.orphaned += 1
|
||||
await logDb.write({
|
||||
@@ -420,9 +436,42 @@ async function reconcileModule(owner) {
|
||||
})
|
||||
}
|
||||
}
|
||||
await closeSettledRuns(settled)
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* One row the game ended at its deadline: `expired`, and its log line. The same
|
||||
* line whichever way core learned it, from the module's `expired()` or from a
|
||||
* reconcile that found it gone past its `until`, so the console reads one way.
|
||||
*/
|
||||
async function settleExpired(owner, row) {
|
||||
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) return false
|
||||
await logDb.write({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.expired',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished run whose last unresolved row just expired has nothing left to
|
||||
* clean up. The sweep would settle a `pending` one by itself, but not an
|
||||
* `incomplete` one — that status takes a run out of the sweep's scan, and it
|
||||
* would go on saying "not finished" about a ledger that is. A run still in
|
||||
* flight is left alone: its own terminal path computes the status.
|
||||
*/
|
||||
async function closeSettledRuns(runIds) {
|
||||
for (const runId of runIds) {
|
||||
const run = await runsDb.getById(runId)
|
||||
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
|
||||
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
|
||||
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask every module that owns a live row. Core's own boot-time sweep. */
|
||||
async function reconcileAll() {
|
||||
const owners = await resourcesDb.modulesWithLiveRows()
|
||||
@@ -469,28 +518,11 @@ async function expireResource(owner, { kind, ref } = {}) {
|
||||
let expired = 0
|
||||
|
||||
for (const row of rows) {
|
||||
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue
|
||||
if (!(await settleExpired(owner, row))) continue
|
||||
expired += 1
|
||||
runs.add(row.run_id)
|
||||
await logDb.write({
|
||||
runId: row.run_id,
|
||||
kind: 'resource.expired',
|
||||
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
|
||||
})
|
||||
}
|
||||
|
||||
// A finished run whose last unresolved row this was has nothing left to clean
|
||||
// up. The sweep would settle a `pending` one by itself, but not an
|
||||
// `incomplete` one — that status takes a run out of the sweep's scan, and it
|
||||
// would go on saying "not finished" about a ledger that is. A run still in
|
||||
// flight is left alone: its own terminal path computes the status.
|
||||
for (const runId of runs) {
|
||||
const run = await runsDb.getById(runId)
|
||||
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
|
||||
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
|
||||
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
|
||||
}
|
||||
}
|
||||
await closeSettledRuns(runs)
|
||||
|
||||
return { expired }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
// DST gap shifts it, it is never rejected), so a schedule that validates here
|
||||
// computes there.
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
|
||||
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
|
||||
@@ -730,6 +732,31 @@ function publishable(spec) {
|
||||
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
|
||||
}
|
||||
|
||||
/** JSON with every object's keys sorted, so equal specs serialise equally. */
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null'
|
||||
}
|
||||
|
||||
/**
|
||||
* A spec's fingerprint: the sha256 of its canonical JSON (W5, Rust
|
||||
* PLAN_LAST_FIXES).
|
||||
*
|
||||
* What lets a dry run that passed on a draft count for the version published
|
||||
* from it. The draft is fingerprinted when the dry run passes and the version
|
||||
* when it is published, both from `validate`'s output, so an edit in between is
|
||||
* a different fingerprint and nothing carries over. Key order is not part of it:
|
||||
* a spec that round-trips through the database with its keys reordered is the
|
||||
* same plan.
|
||||
*/
|
||||
function fingerprint(spec) {
|
||||
return crypto.createHash('sha256').update(canonical(spec)).digest('hex')
|
||||
}
|
||||
|
||||
/** An empty, valid spec — what a newly created draft carries. */
|
||||
const emptySpec = () => ({
|
||||
schedule: { kind: 'manual' },
|
||||
@@ -742,6 +769,7 @@ module.exports = {
|
||||
// 1.13.0): eventDefinitions.model `buildFromTemplate`.
|
||||
checkParams,
|
||||
publishable,
|
||||
fingerprint,
|
||||
actionIdsIn,
|
||||
emptySpec,
|
||||
defaultOnFailure,
|
||||
|
||||
@@ -126,6 +126,18 @@ const markReady = (id, versionId, userId) =>
|
||||
[versionId, userId, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Record a dry run that passed on the draft, with the fingerprint of the spec it
|
||||
* passed on (W5). Publish reads it back through `getById`'s `d.*`.
|
||||
*/
|
||||
const markDraftVerified = (id, { at = new Date(), userId = null, hash }) =>
|
||||
query(
|
||||
`UPDATE event_definitions
|
||||
SET draft_verified_at = ?, draft_verified_by = ?, draft_verified_hash = ?
|
||||
WHERE id = ?`,
|
||||
[at, userId, hash, id],
|
||||
)
|
||||
|
||||
/**
|
||||
* Every definition the runner should expand a recurrence for (Phase 4).
|
||||
*
|
||||
@@ -234,5 +246,6 @@ module.exports = {
|
||||
insert,
|
||||
update,
|
||||
markReady,
|
||||
markDraftVerified,
|
||||
archive,
|
||||
}
|
||||
|
||||
@@ -249,6 +249,16 @@ async function publish(id, userId) {
|
||||
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
|
||||
await db.markReady(id, versionId, userId)
|
||||
|
||||
// A dry run that passed on this exact draft counts for the version made from
|
||||
// it (W5), stamped with that pass's reviewer and time rather than the
|
||||
// publisher's and now: "who last looked at this before it ran unattended" is
|
||||
// the person who ran the check. Any edit since is a different fingerprint.
|
||||
const verified = Boolean(
|
||||
existing.draft_verified_hash &&
|
||||
existing.draft_verified_hash === spec.fingerprint(checked.spec) &&
|
||||
(await versionsDb.markVerified(versionId, existing.draft_verified_by || null, existing.draft_verified_at || new Date())),
|
||||
)
|
||||
|
||||
// Occurrences already materialised ahead of their instant move to the new
|
||||
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
|
||||
// rather than only counted, because "which version did this run actually use"
|
||||
@@ -270,9 +280,18 @@ async function publish(id, userId) {
|
||||
by: userId,
|
||||
},
|
||||
})
|
||||
// The held occurrence is now going to start, and the line saying why
|
||||
// belongs on it, as it does when the version is dry-run by hand.
|
||||
if (verified) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId, version, by: existing.draft_verified_by || null, fromDraft: true },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
|
||||
return { ok: true, versionId, version, repinned, verified, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,6 +392,19 @@ async function verify(id, user) {
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
// A pass on the draft is kept with the fingerprint of the spec it passed on,
|
||||
// for publish to compare (W5). Fingerprinted from `validate`'s output, as
|
||||
// publish fingerprints the spec it freezes; a draft that no longer validates
|
||||
// could not be published anyway, so there is nothing to keep.
|
||||
let draftRecorded = false
|
||||
if (!version && report.ok) {
|
||||
const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec) })
|
||||
if (checked.ok) {
|
||||
await db.markDraftVerified(id, { userId: user?.id || null, hash: spec.fingerprint(checked.spec) })
|
||||
draftRecorded = true
|
||||
}
|
||||
}
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
@@ -397,6 +429,9 @@ async function verify(id, user) {
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
// A pass on the draft, kept so that publishing this spec unchanged makes a
|
||||
// verified version (W5).
|
||||
draftRecorded,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,21 +13,45 @@ async function findByProviderSubject(provider, subject) {
|
||||
// All identities linked to a given internal user (for the Account page).
|
||||
async function listForUser(userId) {
|
||||
return query(
|
||||
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider',
|
||||
`SELECT id, provider, subject, email, display_name, avatar_url, created_at
|
||||
FROM user_identities WHERE user_id = ? ORDER BY provider, created_at`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function insert({ userId, provider, subject, email = null }) {
|
||||
async function insert({ userId, provider, subject, email = null, displayName = null, avatarUrl = null }) {
|
||||
const res = await query(
|
||||
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)',
|
||||
[userId, provider, subject, email],
|
||||
`INSERT INTO user_identities (user_id, provider, subject, email, display_name, avatar_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, provider, subject, email, displayName, avatarUrl],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider. Returns rows deleted.
|
||||
async function deleteForUserProvider(userId, provider) {
|
||||
// Refresh the name and avatar a provider reported at sign-in (Steam, D394). Only
|
||||
// what the provider sent is written: a lookup that failed leaves the last good
|
||||
// values in place rather than blanking them.
|
||||
async function updateProfile(id, { displayName, avatarUrl }) {
|
||||
const sets = []
|
||||
const vals = []
|
||||
if (displayName) { sets.push('display_name = ?'); vals.push(displayName) }
|
||||
if (avatarUrl) { sets.push('avatar_url = ?'); vals.push(avatarUrl) }
|
||||
if (!sets.length) return 0
|
||||
const res = await query(`UPDATE user_identities SET ${sets.join(', ')} WHERE id = ?`, [...vals, id])
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
// Remove a user's link to a provider — every identity of it, or, given an
|
||||
// identity id, that one only (a user may hold several Steam accounts). Returns
|
||||
// rows deleted.
|
||||
async function deleteForUserProvider(userId, provider, identityId = null) {
|
||||
if (identityId != null) {
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ? AND id = ?',
|
||||
[userId, provider, identityId],
|
||||
)
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
const res = await query(
|
||||
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
|
||||
[userId, provider],
|
||||
@@ -35,4 +59,4 @@ async function deleteForUserProvider(userId, provider) {
|
||||
return Number(res.affectedRows || 0)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider }
|
||||
module.exports = { findByProviderSubject, listForUser, insert, updateProfile, deleteForUserProvider }
|
||||
|
||||
@@ -15,13 +15,21 @@ async function listForUser(userId) {
|
||||
|
||||
// Link an external identity to an internal user. Returns the new row id. The
|
||||
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
|
||||
async function link({ userId, provider, subject, email }) {
|
||||
return db.insert({ userId, provider, subject, email })
|
||||
// `displayName` / `avatarUrl` are set by providers that report them (Steam);
|
||||
// the others leave them null.
|
||||
async function link({ userId, provider, subject, email, displayName, avatarUrl }) {
|
||||
return db.insert({ userId, provider, subject, email, displayName, avatarUrl })
|
||||
}
|
||||
|
||||
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider) {
|
||||
return db.deleteForUserProvider(userId, provider)
|
||||
// Refresh a linked identity's name and avatar from a sign-in's profile.
|
||||
async function refreshProfile(identityId, { displayName, avatarUrl } = {}) {
|
||||
return db.updateProfile(identityId, { displayName, avatarUrl })
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, unlink }
|
||||
// Unlink a provider from a user: every identity of it, or the one `identityId`
|
||||
// names. Returns rows removed (0 if nothing was linked).
|
||||
async function unlink(userId, provider, identityId = null) {
|
||||
return db.deleteForUserProvider(userId, provider, identityId)
|
||||
}
|
||||
|
||||
module.exports = { findByProviderSubject, listForUser, link, refreshProfile, unlink }
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,10 +813,26 @@ function load(tierRouters) {
|
||||
return // no modules directory is the normal case for a bare core
|
||||
}
|
||||
|
||||
// One module per site, at boot as at install (`install.js`, org lead
|
||||
// 2026-09-23; W19 in Rust PLAN_LAST_FIXES, 2026-10-22). Install already
|
||||
// refuses a second module, so two on the volume means someone copied one in by
|
||||
// hand. The contract's singletons assume one: Module-uo and Module-Rust both
|
||||
// fill `admin.users.detail`, and the second used to fail at register with a
|
||||
// slot collision that named neither the rule nor the fix. Now the first in
|
||||
// scan order is the site's module, whether or not it then loads, and every
|
||||
// other is refused before a line of its code is required.
|
||||
let holder = null
|
||||
|
||||
for (const id of entries) {
|
||||
const dir = path.join(MODULES_DIR, id)
|
||||
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
|
||||
|
||||
if (holder) {
|
||||
refuseSecond(id, dir, holder)
|
||||
continue
|
||||
}
|
||||
holder = id
|
||||
|
||||
const record = {
|
||||
id,
|
||||
dir,
|
||||
@@ -794,7 +841,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',
|
||||
@@ -855,8 +902,10 @@ function load(tierRouters) {
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// Commit what this module staged. Collisions with core or with an earlier
|
||||
// module surface here, in scan order, and cost only this module.
|
||||
// Commit what this module staged. Collisions with core surface here and
|
||||
// cost only this module. (An earlier module is no longer possible — one
|
||||
// module per site, above — and the module-against-module checks stay as
|
||||
// the second line behind that rule.)
|
||||
registries.apply(record.staged.staged)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
@@ -871,6 +920,45 @@ function load(tierRouters) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a module refused because another already holds the site (W19).
|
||||
*
|
||||
* `startup_failed` at the `manifest` stage, like any module that never reached
|
||||
* register: no routes, no nav, no tables, and Admin → Modules says why. The
|
||||
* name and version are read for that panel and nothing else; a module.json that does not
|
||||
* parse is still refused for the same reason, since the reason is not its
|
||||
* contents.
|
||||
*/
|
||||
function refuseSecond(id, dir, holder) {
|
||||
const manifest = { id, version: 'unknown' }
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'))
|
||||
if (raw && typeof raw.version === 'string') manifest.version = raw.version
|
||||
if (raw && typeof raw.name === 'string') manifest.name = raw.name
|
||||
} catch {
|
||||
// the panel shows "unknown"; the refusal stands either way
|
||||
}
|
||||
const reason =
|
||||
`this site already runs the module "${holder}", and a site runs one module. ` +
|
||||
`Remove "${id}" or "${holder}" from the modules directory; the one that sorts first by name is the one that starts.`
|
||||
modules.set(id, {
|
||||
id,
|
||||
dir,
|
||||
manifest,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: null,
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'startup_failed',
|
||||
stage: 'manifest',
|
||||
reason,
|
||||
})
|
||||
log.error(`module "${id}" was not started — continuing without it`, { stage: 'manifest', reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one module's routers onto the tier routers, behind the dispatch guard.
|
||||
*
|
||||
@@ -1022,6 +1110,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 +1253,7 @@ module.exports = {
|
||||
fragments,
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
identityHooks,
|
||||
stopHook,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.17.0 — a module may take part in linking an external sign-in identity
|
||||
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
|
||||
// Three additions, each optional:
|
||||
// • `ctx.identities.list(userId, provider)` — the identities a user has linked
|
||||
// for one provider, as core verified them (a Steam id proved by Steam's own
|
||||
// `check_authentication`), with the display name and avatar when there are;
|
||||
// • `api.onIdentityLinking(fn)` — asked before a link is written, and able only
|
||||
// to refuse it: `{ ok: false, reason }`, where `reason: 'in_use'` reads to the
|
||||
// player as "already linked to another account" and anything else as a
|
||||
// plain refusal. A module's own text is logged, never shown;
|
||||
// • `api.onIdentityLinked(fn)` — told after a link is written.
|
||||
// No unlink hook (D393). Additions only, so minor. Module-uo declares `^1.10.0`
|
||||
// and uses none of them: with no hook registered a link is checked and written
|
||||
// exactly as before.
|
||||
//
|
||||
// 1.16.0 — a dry run carries a module's advice: an action that answers
|
||||
// `verify: true` with `{ ok: true, detail: { warnings: [...] } }` has each line
|
||||
// shown as a WARNING finding (code `advice`) that does not fail the dry run
|
||||
@@ -216,6 +231,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.16.0'
|
||||
const MODULE_API_VERSION = '1.17.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ── Admin: auth provider configuration ─────────────────────────────────────
|
||||
//
|
||||
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
|
||||
// CRUD for SSO providers. Built-ins (google, discord, steam) are configured here too but
|
||||
// can only be enabled/disabled and given a client id/secret — their kind, name,
|
||||
// and endpoints are fixed in code and cannot be edited or deleted. Custom
|
||||
// (oidc/oauth2) providers are fully editable.
|
||||
@@ -80,6 +80,18 @@ async function update(req, res) {
|
||||
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
|
||||
const meta = registry.BUILTINS.find((x) => x.id === id)
|
||||
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
|
||||
if (meta.kind === 'steam') {
|
||||
// Steam has no client id; its secret is the Steam Web API key, and it
|
||||
// cannot be enabled without one (D394) — refused here, with a sentence,
|
||||
// rather than saved as an "Incomplete" provider nobody can use.
|
||||
fields.clientId = undefined
|
||||
if (b.enabled === true && !b.secret) {
|
||||
const current = await authProviders.get(id)
|
||||
if (!current || !current.client_secret_enc) {
|
||||
return res.status(400).json({ message: 'Steam needs a Steam Web API key before it can be enabled.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await authProviders.get(id))) {
|
||||
return res.status(404).json({ message: 'Provider not found.' })
|
||||
|
||||
@@ -59,11 +59,12 @@ providersRouter.put(
|
||||
'/providers/:id',
|
||||
// #swagger.tags = ['Admin · Auth Providers']
|
||||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||||
// #swagger.description = 'Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or Steam enabled without a Steam Web API key', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -445,7 +445,13 @@ exports.publish = async (req, res) => {
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.published',
|
||||
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
|
||||
detail: {
|
||||
id,
|
||||
version: result.version,
|
||||
versionId: result.versionId,
|
||||
repinned: result.repinned,
|
||||
verified: result.verified,
|
||||
},
|
||||
})
|
||||
return res.json({
|
||||
event: await shapeEditable(result.definition),
|
||||
@@ -455,6 +461,9 @@ exports.publish = async (req, res) => {
|
||||
// screen says so, because "my fix did not reach next Friday" is otherwise
|
||||
// found out on Friday.
|
||||
repinned: result.repinned,
|
||||
// Whether a dry run that passed on this exact draft made the version
|
||||
// verified at birth (W5), so the screen can say it needs no second one.
|
||||
verified: result.verified,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,6 +501,7 @@ exports.verify = async (req, res) => {
|
||||
versionId: result.versionId,
|
||||
version: result.version,
|
||||
recorded: result.recorded,
|
||||
draftRecorded: result.draftRecorded,
|
||||
report: result.report,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -475,9 +475,9 @@ eventsRouter.post(
|
||||
'/:id/verify',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
|
||||
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
|
||||
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, draftRecorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -489,9 +489,9 @@ eventsRouter.post(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
|
||||
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" }, verified: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
|
||||
@@ -339,20 +339,36 @@ async function totpDisable(req, res) {
|
||||
async function listIdentities(req, res) {
|
||||
try {
|
||||
const rows = await userIdentities.listForUser(req.user.id)
|
||||
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
|
||||
// `id` lets one identity be unlinked when a provider has several (a player
|
||||
// may link more than one Steam account). `subject` is the user's OWN external
|
||||
// id — for Steam, the SteamID64 shown beside the persona name (D394).
|
||||
return res.json(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
provider: r.provider,
|
||||
subject: r.subject,
|
||||
email: r.email,
|
||||
display_name: r.display_name || null,
|
||||
avatar_url: r.avatar_url || null,
|
||||
linked_at: r.created_at,
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('listIdentities', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a linked SSO identity from the current user's account.
|
||||
// Remove a linked SSO identity from the current user's account: every identity
|
||||
// of that provider, or with `?id=` the one identity named. Only the sign-in goes —
|
||||
// a module's own link made from it (Rust's, D392) is the module's to remove (D393).
|
||||
async function unlinkIdentity(req, res) {
|
||||
const { provider } = req.params
|
||||
const identityId = req.query.id != null ? Number(req.query.id) : null
|
||||
try {
|
||||
const removed = await userIdentities.unlink(req.user.id, provider)
|
||||
const removed = await userIdentities.unlink(req.user.id, provider, identityId)
|
||||
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
|
||||
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider, ...(identityId != null ? { identityId } : {}) } })
|
||||
log.info('sso identity unlinked', { provider, id: req.user.id })
|
||||
return res.json({ unlinked: true })
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// expect. Mounted at /me by auth/index.js, so paths below are /auth/me/account*.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
const { body, param, query } = require('express-validator')
|
||||
|
||||
const account = require('./account.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
@@ -177,10 +177,14 @@ meRouter.delete(
|
||||
// #swagger.tags = ['Auth · Me']
|
||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||
// #swagger.parameters['id'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'One identity to unlink (its id from GET /auth/me/account/identities).' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('provider').matches(/^[a-z0-9-]+$/),
|
||||
query('id').optional().isInt({ min: 1 }),
|
||||
validate,
|
||||
account.unlinkIdentity,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
|
||||
//
|
||||
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
|
||||
// Drives the redirect flow for built-in (Google, Discord, Steam) and custom
|
||||
// providers. Steam is OpenID 2.0 rather than OAuth2 (STEAM_SIGNIN.md §3); the
|
||||
// difference is confined to readCallback() and the provider class:
|
||||
// GET /auth/providers → public discovery (enabled + valid providers)
|
||||
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
|
||||
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
|
||||
@@ -17,6 +19,8 @@ const authProviders = require('../../../model/authProviders/authProviders.model'
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
const registry = require('../../../auth/providers/registry')
|
||||
const SteamProvider = require('../../../auth/providers/steam.provider')
|
||||
const identityHooks = require('../../../auth/identityHooks')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const mobileBridge = require('../../../model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const ssoState = require('../../../auth/ssoState')
|
||||
@@ -148,20 +152,43 @@ async function beginFlow(req, res, mode) {
|
||||
const start = (req, res) => beginFlow(req, res, 'login')
|
||||
const linkStart = (req, res) => beginFlow(req, res, 'link')
|
||||
|
||||
// The callback's own answer from the IdP, read the way its protocol writes it.
|
||||
// OAuth2/OIDC sends `code` (or `error`); Steam's OpenID 2.0 sends `openid.*`
|
||||
// fields, with `openid.mode=cancel` when the person declined (STEAM_SIGNIN.md §3).
|
||||
// Our `state` arrives as a query parameter either way — for Steam because it
|
||||
// rides inside the `return_to` we sent.
|
||||
function readCallback(req, providerId) {
|
||||
const q = req.query || {}
|
||||
if (registry.isOpenId(providerId)) {
|
||||
const mode = typeof q['openid.mode'] === 'string' ? q['openid.mode'] : ''
|
||||
return { state: q.state, denied: mode === 'cancel' || mode === 'error', present: mode === 'id_res' }
|
||||
}
|
||||
return { state: q.state, denied: Boolean(q.error), present: Boolean(q.code), error: q.error }
|
||||
}
|
||||
|
||||
// What the provider's handleCallback needs. Steam takes the whole query, and its
|
||||
// `return_to` must match what we sent (the callback URL plus this very state)
|
||||
// byte for byte; OAuth2 takes the code and the PKCE verifier.
|
||||
function callbackParams(req, providerId, tx, answer) {
|
||||
const redirectUri = redirectUriFor(req, providerId)
|
||||
if (registry.isOpenId(providerId)) return { query: req.query, redirectUri, state: answer.state }
|
||||
return { code: req.query.code, redirectUri, codeVerifier: tx.verifier }
|
||||
}
|
||||
|
||||
// GET /auth/sso/:provider/callback
|
||||
async function callback(req, res) {
|
||||
const providerId = req.params.provider
|
||||
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
|
||||
const { code, state, error: oauthError } = req.query
|
||||
const answer = readCallback(req, providerId)
|
||||
// The tx cookie is single-use — clear it no matter the outcome.
|
||||
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
|
||||
|
||||
if (oauthError) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
|
||||
if (answer.denied) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(answer.error || 'cancel').slice(0, 60) })
|
||||
return res.redirect(loginError('denied'))
|
||||
}
|
||||
const tx = ssoState.verifyTx(txToken, state)
|
||||
if (!tx || tx.provider !== providerId || !code) {
|
||||
const tx = ssoState.verifyTx(txToken, answer.state)
|
||||
if (!tx || tx.provider !== providerId || !answer.present) {
|
||||
log.warn('sso callback: bad state', { provider: providerId })
|
||||
return res.redirect(loginError('bad_state'))
|
||||
}
|
||||
@@ -175,20 +202,34 @@ async function callback(req, res) {
|
||||
return res.redirect(failFor('unavailable'))
|
||||
}
|
||||
const provider = registry.instantiate(row)
|
||||
const profile = await provider.handleCallback({
|
||||
code,
|
||||
redirectUri: redirectUriFor(req, providerId),
|
||||
codeVerifier: tx.verifier,
|
||||
})
|
||||
const profile = await provider.handleCallback(callbackParams(req, providerId, tx, answer))
|
||||
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
||||
if (tx.mode === 'mobile') return finishMobileLogin(req, res, providerId, row.kind, tx, profile)
|
||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||
} catch (err) {
|
||||
// A Steam assertion that failed one of the §3 checks is a refused sign-in, not
|
||||
// a server fault: it is what a replayed, forged or tampered callback looks like.
|
||||
if (err instanceof SteamProvider.SteamAssertionError) {
|
||||
log.warn('sso callback: steam assertion refused', { provider: providerId, reason: err.reason })
|
||||
return res.redirect(failFor('bad_state'))
|
||||
}
|
||||
log.error('sso callback', err)
|
||||
return res.redirect(failFor('error'))
|
||||
}
|
||||
}
|
||||
|
||||
// A profile's name and avatar written back onto an identity the user already
|
||||
// holds, when the provider reports them (Steam, at every sign-in). Decoration:
|
||||
// a failure here is logged and never stops the sign-in it rides on.
|
||||
async function refreshIdentityProfile(identity, profile) {
|
||||
if (!identity || !(profile.displayName || profile.avatarUrl)) return
|
||||
try {
|
||||
await userIdentities.refreshProfile(identity.id, { displayName: profile.displayName, avatarUrl: profile.avatarUrl })
|
||||
} catch (err) {
|
||||
log.warn('sso: could not refresh a linked identity profile', { provider: identity.provider, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
||||
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
||||
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
||||
@@ -288,10 +329,12 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||
if (identity) {
|
||||
user = await users.getById(identity.user_id)
|
||||
if (!user) return res.redirect(loginError('not_linked', portal))
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
} else {
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
|
||||
// Unknown identity: auto-provision only if registration opts into SSO sign-up,
|
||||
// and never through a provider that signs nobody up (Steam, D395).
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(loginError('not_linked', portal))
|
||||
}
|
||||
@@ -397,7 +440,7 @@ async function requireValidBridgeSession(res, tx, providerId) {
|
||||
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
|
||||
// provisioning when registration mode allows it). On refusal, sends the redirect
|
||||
// and returns null.
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, kind, profile) {
|
||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (identity) {
|
||||
const user = await users.getById(identity.user_id)
|
||||
@@ -405,10 +448,11 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
await refreshIdentityProfile(identity, profile)
|
||||
return user
|
||||
}
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
if ((mode !== 'sso' && mode !== 'both') || registry.neverProvisions(kind)) {
|
||||
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
@@ -425,7 +469,7 @@ async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
const sess = await requireValidBridgeSession(res, tx, providerId)
|
||||
if (!sess) return
|
||||
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, kind, profile)
|
||||
if (!user) return
|
||||
|
||||
if (user.status && user.status !== 'active') {
|
||||
@@ -555,11 +599,38 @@ async function finishLink(req, res, providerId, tx, profile) {
|
||||
if (existing && existing.user_id !== userId) {
|
||||
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
|
||||
}
|
||||
if (!existing) {
|
||||
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
if (existing) {
|
||||
// Already theirs: linking twice is not an error, and it is a fresh look at
|
||||
// the name and avatar.
|
||||
await refreshIdentityProfile(existing, profile)
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
// Modules may refuse before anything is written (API 1.17.0) — Rust, for a
|
||||
// Steam id that is already another account's Rust link (D396). Only ever a
|
||||
// refusal: this user is already signed in, and this decides a link, not a session.
|
||||
const check = await identityHooks.checkLink({ userId, provider: providerId, subject: profile.subject })
|
||||
if (!check.ok) {
|
||||
await activity.log({ req, userId, action: 'auth.sso.link.refused', detail: { provider: providerId, module: check.module, reason: check.reason } })
|
||||
return res.redirect(accountError(check.reason, portal))
|
||||
}
|
||||
|
||||
await userIdentities.link({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
email: profile.email,
|
||||
displayName: profile.displayName || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
})
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
await identityHooks.notifyLinked({
|
||||
userId,
|
||||
provider: providerId,
|
||||
subject: profile.subject,
|
||||
displayName: profile.displayName || null,
|
||||
})
|
||||
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,13 @@ ssoRouter.get(
|
||||
'/sso/:provider/callback',
|
||||
// #swagger.tags = ['Auth · SSO']
|
||||
// #swagger.summary = 'OAuth redirect target — completes login or linking'
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code.' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie).' }
|
||||
// #swagger.description = 'The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).'
|
||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id (e.g. google, discord, steam).' }
|
||||
// #swagger.parameters['code'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth authorization code (OAuth2/OIDC providers).' }
|
||||
// #swagger.parameters['state'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter.' }
|
||||
// #swagger.parameters['error'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'OAuth error (the user declined, or the IdP refused).' }
|
||||
// #swagger.parameters['openid.mode'] = { in: 'query', required: false, schema: { type: 'string', enum: ['id_res', 'cancel'] }, description: 'Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify.' }
|
||||
// #swagger.parameters['openid.claimed_id'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Steam only: https://steamcommunity.com/openid/id/<SteamID64>.' }
|
||||
/* #swagger.responses[302] = { description: 'Redirect into /admin on success, or back to login/account with an error code' } */
|
||||
ctrl.callback,
|
||||
)
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
"Admin · Auth Providers"
|
||||
],
|
||||
"summary": "Update an SSO provider (admin only)",
|
||||
"description": "",
|
||||
"description": "Built-ins (google, discord, steam) take only enabled + credentials. Steam has no client id: its secret is a Steam Web API key, and enabling Steam without one stored or given is refused with 400 (STEAM_SIGNIN.md D394).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -376,7 +376,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"description": "Validation error, or Steam enabled without a Steam Web API key",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -5962,7 +5962,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "Snapshot the working spec into an immutable version and mark the definition ready",
|
||||
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.",
|
||||
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -5993,6 +5993,9 @@
|
||||
},
|
||||
"repinned": {
|
||||
"type": "integer"
|
||||
},
|
||||
"verified": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6188,7 +6191,7 @@
|
||||
"Admin · Events"
|
||||
],
|
||||
"summary": "Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps",
|
||||
"description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.",
|
||||
"description": "EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -6220,6 +6223,9 @@
|
||||
"recorded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"draftRecorded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13089,7 +13095,7 @@
|
||||
"Auth · Me"
|
||||
],
|
||||
"summary": "Unlink an SSO identity (self)",
|
||||
"description": "",
|
||||
"description": "Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
@@ -13099,6 +13105,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id."
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "One identity to unlink (its id from GET /auth/me/account/identities)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -13113,7 +13128,14 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
@@ -15467,7 +15489,7 @@
|
||||
"Auth · SSO"
|
||||
],
|
||||
"summary": "OAuth redirect target — completes login or linking",
|
||||
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).",
|
||||
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
@@ -15476,14 +15498,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id (e.g. google, discord)."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"description": "Provider id (e.g. google, discord, steam)."
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
@@ -15492,7 +15507,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth authorization code."
|
||||
"description": "OAuth authorization code (OAuth2/OIDC providers)."
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
@@ -15501,7 +15516,50 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth state (matched against the tx cookie)."
|
||||
"description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter."
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "OAuth error (the user declined, or the IdP refused)."
|
||||
},
|
||||
{
|
||||
"name": "openid.mode",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"id_res",
|
||||
"cancel"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify."
|
||||
},
|
||||
{
|
||||
"name": "openid.claimed_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Steam only: https://steamcommunity.com/openid/id/<SteamID64>."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -19827,7 +19885,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')."
|
||||
"example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
@@ -19908,6 +19966,7 @@
|
||||
"example": [
|
||||
"google",
|
||||
"discord",
|
||||
"steam",
|
||||
"oidc",
|
||||
"oauth2"
|
||||
],
|
||||
@@ -19965,7 +20024,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Whether a client secret is stored (the secret itself is never returned)."
|
||||
"example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -20047,7 +20106,7 @@
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "True for the fixed google/discord providers."
|
||||
"example": "True for the fixed google/discord/steam providers."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
@@ -24740,6 +24799,23 @@
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Identity id, for unlinking one of several (DELETE ...?id=)."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
}
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24774,6 +24850,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "76561197960287930"
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Persona name the provider reported (steam only)."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "CoolGuy99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"avatar_url": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "uri"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Avatar on the provider’s CDN (steam only)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"linked_at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -294,7 +294,7 @@ const doc = {
|
||||
name: { type: 'string', example: 'Google' },
|
||||
icon: {
|
||||
type: 'string',
|
||||
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
|
||||
description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').",
|
||||
example: 'google',
|
||||
},
|
||||
loginUrl: {
|
||||
@@ -313,17 +313,17 @@ const doc = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'okta' },
|
||||
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
|
||||
kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' },
|
||||
name: { type: 'string', example: 'Okta' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
clientId: { type: 'string' },
|
||||
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
|
||||
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true },
|
||||
authorizeUrl: { type: 'string', format: 'uri' },
|
||||
tokenUrl: { type: 'string', format: 'uri' },
|
||||
userinfoUrl: { type: 'string', format: 'uri' },
|
||||
scopes: { type: 'string', example: 'openid email profile' },
|
||||
priority: { type: 'integer', example: 10 },
|
||||
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
|
||||
builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false },
|
||||
health: { $ref: '#/components/schemas/ProviderHealth' },
|
||||
},
|
||||
},
|
||||
@@ -961,8 +961,12 @@ const doc = {
|
||||
LinkedIdentity: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 },
|
||||
provider: { type: 'string', example: 'google' },
|
||||
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
|
||||
subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' },
|
||||
display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' },
|
||||
avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' },
|
||||
linked_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -478,7 +478,7 @@ test('a resource the module no longer has becomes orphaned, never reverted', asy
|
||||
addResource({ ref: '0x2' })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 2, inForce: 1, orphaned: 1, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 2, inForce: 1, orphaned: 1, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()].find((r) => r.ref === '0x2').status, 'orphaned')
|
||||
assert.equal([...store.rows.values()].find((r) => r.ref === '0x1').status, 'confirmed')
|
||||
assert.ok(store.log.some((e) => e.kind === 'resource.orphaned'))
|
||||
@@ -510,7 +510,7 @@ test('a module with no reconcile is not broken; core keeps believing its ledger'
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource()
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 1 })
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 1 })
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
@@ -532,7 +532,7 @@ test('placeholders are not asked about, because there is nothing to ask yet', as
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource({ kind: resourcesDb.STEP_KIND, ref: 'a'.repeat(40), status: 'pending' })
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'pending')
|
||||
})
|
||||
|
||||
@@ -556,7 +556,7 @@ test('a lease row had no reconcile path at all until core.lease grew one', async
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 0, orphaned: 1, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'orphaned')
|
||||
})
|
||||
|
||||
@@ -566,7 +566,7 @@ test('a lease the shard still has a record of stays put', async () => {
|
||||
addResource({ kind: 'override', ref: 'demo.rate', payload: { baseline: 1, applied: 3 } })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo')
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, unanswered: 0 })
|
||||
assert.deepEqual(summary, { asked: 1, inForce: 1, orphaned: 0, expired: 0, unanswered: 0 })
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
@@ -705,6 +705,61 @@ test('an expiry nobody ledgered, or a malformed one, is not an error', async ()
|
||||
assert.equal(store.log.length, 0)
|
||||
})
|
||||
|
||||
// W1 (Rust PLAN_LAST_FIXES): on a reconnect the module's reconcile can run
|
||||
// before the game's backlog reports an expiry. A row past its `until` that the
|
||||
// game no longer has is the deadline working, so it is filed expired at once
|
||||
// rather than orphaned and then expired five seconds later.
|
||||
|
||||
test('a reconcile files a row gone past its until as expired, not orphaned', async () => {
|
||||
stubExpiry()
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
const now = new Date('2026-10-22T14:06:35Z')
|
||||
addResource({ kind: 'zone', ref: 'past', lease_until: new Date('2026-10-22T14:06:00Z') })
|
||||
addResource({ kind: 'zone', ref: 'future', lease_until: new Date('2026-10-22T14:30:00Z') })
|
||||
addResource({ kind: 'zone', ref: 'none' })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo', now)
|
||||
assert.deepEqual(summary, { asked: 3, inForce: 0, orphaned: 2, expired: 1, unanswered: 0 })
|
||||
const status = (ref) => [...store.rows.values()].find((r) => r.ref === ref).status
|
||||
assert.equal(status('past'), 'expired')
|
||||
// Before its deadline, or with none, gone is still nobody-was-looking.
|
||||
assert.equal(status('future'), 'orphaned')
|
||||
assert.equal(status('none'), 'orphaned')
|
||||
assert.deepEqual(
|
||||
store.log.filter((e) => e.detail.resource === 'zone:past').map((e) => e.kind),
|
||||
['resource.expired'],
|
||||
)
|
||||
|
||||
// The game's own word arriving afterwards finds nothing left to close.
|
||||
assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: 'past' }), { expired: 0 })
|
||||
assert.equal(store.log.filter((e) => e.detail.resource === 'zone:past').length, 1)
|
||||
})
|
||||
|
||||
test('a row past its until that the game still has stays in force', async () => {
|
||||
// A deadline is the game's promise, not core's clock: a zone the game has not
|
||||
// erased yet is still up, whatever core's time says.
|
||||
stubExpiry()
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: ['late'] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource({ kind: 'zone', ref: 'late', lease_until: new Date('2026-10-22T14:00:00Z') })
|
||||
|
||||
const summary = await cleanup.reconcileModule('demo', new Date('2026-10-22T14:06:35Z'))
|
||||
assert.equal(summary.inForce, 1)
|
||||
assert.equal([...store.rows.values()][0].status, 'confirmed')
|
||||
})
|
||||
|
||||
test('a reconcile that expires the last row of a finished run completes its cleanup', async () => {
|
||||
stubExpiry()
|
||||
registerAction({ reconcile: async () => ({ ok: true, inForce: [] }) })
|
||||
addStep(1, 'demo.spawn')
|
||||
addResource({ kind: 'zone', ref: 'x', lease_until: new Date('2026-10-22T14:00:00Z') })
|
||||
store.cleanupStatus = 'incomplete'
|
||||
|
||||
await cleanup.reconcileModule('demo', new Date('2026-10-22T14:06:35Z'))
|
||||
assert.equal(store.cleanupStatus, 'complete')
|
||||
})
|
||||
|
||||
test('the last row of a finished run expiring completes an incomplete cleanup', async () => {
|
||||
stubExpiry()
|
||||
addResource({ kind: 'zone', ref: 'x' })
|
||||
|
||||
@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
|
||||
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
|
||||
// assertion that says it does. The number itself is pinned in version.js.
|
||||
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
|
||||
assert.equal(MODULE_API_VERSION, '1.16.0')
|
||||
assert.equal(MODULE_API_VERSION, '1.17.0')
|
||||
})
|
||||
|
||||
test('a module registers actions, budgets, leases and option sources', () => {
|
||||
@@ -694,14 +694,24 @@ test('inForce() is optional, and a fourth question rather than a fourth spelling
|
||||
}])
|
||||
}`
|
||||
|
||||
//
|
||||
// Each in a modules directory of its own, because a site runs one module (W19):
|
||||
// side by side, `leaseb` and `leasec` would be refused for `leasea`'s sake.
|
||||
const fresh = () => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
|
||||
}
|
||||
|
||||
const without = loadModule('leasea', lease('leasea', ''))
|
||||
assertRegistered(without)
|
||||
assert.equal(registries.eventLease('leasea.rate.gain').inForce, null)
|
||||
|
||||
fresh()
|
||||
const withIt = loadModule('leaseb', lease('leaseb', 'async inForce() { return { ok: true, held: true } },'))
|
||||
assertRegistered(withIt)
|
||||
assert.equal(typeof registries.eventLease('leaseb.rate.gain').inForce, 'function')
|
||||
|
||||
fresh()
|
||||
|
||||
// And a declaration that is present but not callable fails the module rather
|
||||
// than being ignored -- the same posture the other three take. A module that
|
||||
// meant to answer and cannot is a module whose leases would silently never be
|
||||
|
||||
@@ -477,3 +477,20 @@ test('the size bounds hold', () => {
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), new RegExp(`at most ${spec.MAX_PHASES} phases`))
|
||||
})
|
||||
|
||||
test('a fingerprint is the plan, not the order its keys were written in', () => {
|
||||
// W5: publish compares the fingerprint of the spec it freezes with the one a
|
||||
// draft dry run kept. A spec that round-trips through a JSON column with its
|
||||
// keys reordered is the same plan and must carry its pass over.
|
||||
const a = { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] }
|
||||
const b = { phases: [{ steps: [], label: 'Main', key: 'main' }], schedule: { kind: 'manual' } }
|
||||
assert.equal(spec.fingerprint(a), spec.fingerprint(b))
|
||||
assert.match(spec.fingerprint(a), /^[0-9a-f]{64}$/)
|
||||
|
||||
// Array order IS the plan: steps run in it.
|
||||
const c = { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [1, 2] }] }
|
||||
const d = { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [2, 1] }] }
|
||||
assert.notEqual(spec.fingerprint(c), spec.fingerprint(d))
|
||||
// And a key left undefined is the key left out, as JSON has it.
|
||||
assert.equal(spec.fingerprint({ ...a, starts: undefined }), spec.fingerprint(a))
|
||||
})
|
||||
|
||||
@@ -152,6 +152,12 @@ function installStubs() {
|
||||
Object.assign(existing, { state: 'ready', current_version_id: versionId, updated_by: userId })
|
||||
}
|
||||
}
|
||||
definitionsDb.markDraftVerified = async (id, { at = new Date(), userId = null, hash }) => {
|
||||
const existing = store.definitions.get(id)
|
||||
if (existing) {
|
||||
Object.assign(existing, { draft_verified_at: at, draft_verified_by: userId, draft_verified_hash: hash })
|
||||
}
|
||||
}
|
||||
definitionsDb.archive = async (id, userId) => {
|
||||
const existing = store.definitions.get(id)
|
||||
if (existing) Object.assign(existing, { state: 'archived', updated_by: userId })
|
||||
@@ -998,6 +1004,106 @@ test('a draft is verified against its working spec, and the pass is not recorded
|
||||
assert.equal(res.body.recorded, false)
|
||||
assert.equal(res.body.report.ok, true)
|
||||
assert.equal(res.body.report.steps, 1)
|
||||
// Kept for publish to compare against instead (W5).
|
||||
assert.equal(res.body.draftRecorded, true)
|
||||
})
|
||||
|
||||
// W5 (Rust PLAN_LAST_FIXES): an author who dry-ran the draft and then published
|
||||
// found v1 "not dry run", and its scheduled and trigger starts held, until they
|
||||
// ran the same check again.
|
||||
|
||||
test('a dry run that passed on the draft counts for the version published from it', async () => {
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
const passedAt = store.definitions.get(id).draft_verified_at
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.statusCode, 200)
|
||||
assert.equal(published.body.verified, true)
|
||||
|
||||
// The reviewer and time are the dry run's, not the publisher's and now.
|
||||
const version = store.versions.get(published.body.versionId)
|
||||
assert.equal(version.verified_by, EDITOR.id)
|
||||
assert.equal(version.verified_at, passedAt)
|
||||
const after = await call(ctrl.get, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.ok(after.body.event.currentVersionVerifiedAt)
|
||||
})
|
||||
|
||||
test('an edit after the draft dry run means nothing carries over', async () => {
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
await call(ctrl.update, {
|
||||
user: EDITOR,
|
||||
params: { id: String(id) },
|
||||
body: draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [announceStep('The gates open at midnight.')] }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.verified, false)
|
||||
assert.equal(store.versions.get(published.body.versionId).verified_at, null)
|
||||
})
|
||||
|
||||
test('a title edit after the draft dry run still carries it over, because the plan is the same', async () => {
|
||||
// The fingerprint is the spec's, and the title is not part of the plan a dry
|
||||
// run checks.
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
await call(ctrl.update, { user: EDITOR, params: { id: String(id) }, body: { title: 'Renamed' } })
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.verified, true)
|
||||
})
|
||||
|
||||
test('a draft dry run with findings keeps nothing to carry over', async () => {
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
|
||||
const res = await call(ctrl.verify, { user: EDITOR, params: { id: String(id) } })
|
||||
assert.equal(res.body.report.ok, false)
|
||||
assert.equal(res.body.draftRecorded, false)
|
||||
|
||||
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: true } })
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.verified, false)
|
||||
})
|
||||
|
||||
test('the carried pass is written on the occurrences that publish re-pins', async () => {
|
||||
// v1 published and scheduled, then v2 published from a draft that was
|
||||
// dry-run first: the held occurrence moves to v2 and says why it may start.
|
||||
const { body } = await createDraft()
|
||||
const id = body.event.id
|
||||
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
await call(ctrl.startRun, { user: ADMIN, params: { id: String(id) }, body: { scheduledFor: '2099-11-01T18:00:00.000Z' } })
|
||||
// A ready definition dry-runs its published version, so the draft pass is
|
||||
// planted the way a dry run on the draft would have left it.
|
||||
const edited = draftBody({
|
||||
spec: {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [announceStep('Later.')] }],
|
||||
},
|
||||
})
|
||||
await call(ctrl.update, { user: EDITOR, params: { id: String(id) }, body: edited })
|
||||
const spec = require('../src/events/spec')
|
||||
const saved = store.definitions.get(id).spec
|
||||
const checked = spec.validate(saved, { knownActionIds: spec.actionIdsIn(saved) })
|
||||
store.definitions.get(id).draft_verified_hash = spec.fingerprint(checked.spec)
|
||||
store.definitions.get(id).draft_verified_by = EDITOR.id
|
||||
|
||||
const published = await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
|
||||
assert.equal(published.body.repinned, 1)
|
||||
assert.equal(published.body.verified, true)
|
||||
const line = store.log.find((e) => e.kind === 'version.verified')
|
||||
assert.ok(line, 'the re-pinned occurrence says it may now start')
|
||||
assert.equal(line.detail.fromDraft, true)
|
||||
assert.equal(line.detail.versionId, published.body.versionId)
|
||||
})
|
||||
|
||||
test('a ready definition is verified against the version that would actually run, and the pass IS recorded', async () => {
|
||||
|
||||
@@ -143,10 +143,9 @@ beforeEach(() => {
|
||||
|
||||
// ── The happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
test('every module is recorded, booted with its ctx and marked started, in scan order', async () => {
|
||||
test('the site\'s module is recorded, booted with its ctx and marked started', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
@@ -155,15 +154,35 @@ test('every module is recorded, booted with its ctx and marked started, in scan
|
||||
// The reconcile order IS the design (§2.4): clear the last boot's outcomes
|
||||
// first, so what is on display afterwards is what this boot did.
|
||||
assert.equal(model.calls[0], 'beginBoot')
|
||||
assert.deepEqual(model.calls.slice(1, 3), ['recordInstalled:aaa', 'recordInstalled:bbb'])
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx'])
|
||||
assert.equal(model.calls[1], 'recordInstalled:aaa')
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'started')
|
||||
assert.equal(model.rows.get('bbb').state, 'started')
|
||||
assert.equal(model.rows.get('aaa').state, 'started')
|
||||
// §2.4's metadata refresh: the row carries what the admin screen shows.
|
||||
assert.equal(model.rows.get('aaa').name, 'Module aaa')
|
||||
assert.equal(model.rows.get('aaa').version, '1.2.3')
|
||||
})
|
||||
|
||||
test('a second module on the volume gets a failed row and is never booted', async () => {
|
||||
// W19. Hand-copied beside the site's module, it still needs a row: without one
|
||||
// Admin → Modules could not say why it is not running.
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx'])
|
||||
assert.equal(model.rows.get('aaa').state, 'started')
|
||||
const refused = model.rows.get('bbb')
|
||||
assert.equal(refused.state, 'startup_failed')
|
||||
assert.equal(refused.failureStage, 'manifest')
|
||||
assert.match(refused.failureReason, /already runs the module "aaa", and a site runs one module/)
|
||||
assert.equal(refused.name, 'Module bbb')
|
||||
})
|
||||
|
||||
test('a hand-placed directory gets a row with no provenance', async () => {
|
||||
// §2.5 keeps a directory dropped on the volume by hand a supported install.
|
||||
// Without a row it could never be disabled, and nothing could report it.
|
||||
@@ -193,23 +212,19 @@ test('a module with no onBoot still reaches started', async () => {
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('an onBoot that throws fails its own module and no one else', async () => {
|
||||
test('an onBoot that throws is a failed state, not a failed boot', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: 'throw new Error("cache warm-up failed")', log: file })
|
||||
writeModule('ccc', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
await assert.doesNotReject(() => lifecycle.boot({ modules: loader, model }))
|
||||
|
||||
assert.deepEqual(noted(file), ['boot:bbb:ctx'])
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
||||
assert.equal(stateOf(loader, 'bbb').stage, 'boot')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /cache warm-up failed/)
|
||||
assert.equal(model.rows.get('bbb').failureStage, 'boot')
|
||||
// The one that matters: the module AFTER the failure still booted.
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx', 'boot:ccc:ctx'])
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'started')
|
||||
})
|
||||
|
||||
test('a module whose onBoot failed keeps its URLs and answers 503', async () => {
|
||||
@@ -333,25 +348,28 @@ test('a process that never scanned writes nothing at all', async () => {
|
||||
|
||||
// ── Shutdown ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('shutdown runs started modules in reverse order and skips the rest', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('bbb', { boot: 'throw new Error("no")', shutdown: '', log: file })
|
||||
writeModule('ccc', { boot: '', shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
test('shutdown runs a started module and skips one whose boot failed', async () => {
|
||||
for (const [boot, expected] of [
|
||||
['', ['shutdown:aaa']],
|
||||
// Its onBoot threw, so it has a half-built world that its onShutdown was
|
||||
// never written to tear down.
|
||||
['throw new Error("no")', []],
|
||||
]) {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-lifecycle-'))
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot, shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '') // only the shutdown half is under test
|
||||
await lifecycle.shutdown({ modules: loader })
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '') // only the shutdown half is under test
|
||||
await lifecycle.shutdown({ modules: loader })
|
||||
|
||||
// Reverse of boot order, and `bbb` absent: its onBoot threw, so it has a
|
||||
// half-built world that its onShutdown was never written to tear down.
|
||||
assert.deepEqual(noted(file), ['shutdown:ccc', 'shutdown:aaa'])
|
||||
assert.deepEqual(noted(file).filter(Boolean), expected)
|
||||
}
|
||||
})
|
||||
|
||||
test('a hook that hangs costs its budget, not the shutdown', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'await new Promise(() => {})', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
@@ -361,15 +379,14 @@ test('a hook that hangs costs its budget, not the shutdown', async () => {
|
||||
const started = Date.now()
|
||||
await lifecycle.shutdown({ modules: loader, budgetMs: 50 })
|
||||
|
||||
// zzz never returns; it is abandoned and aaa still gets its turn. The
|
||||
// alternative is a host where `systemctl stop` hangs until SIGKILL.
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
// zzz never returns, and is abandoned. The alternative is a host where
|
||||
// `systemctl stop` hangs until SIGKILL.
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz'])
|
||||
assert.ok(Date.now() - started < 2000, 'shutdown must not wait on a hung hook')
|
||||
})
|
||||
|
||||
test('a hook that throws does not stop the ones behind it', async () => {
|
||||
test('a hook that throws does not fail the shutdown', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'throw new Error("close failed")', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
@@ -377,7 +394,7 @@ test('a hook that throws does not stop the ones behind it', async () => {
|
||||
fs.writeFileSync(file, '')
|
||||
|
||||
await assert.doesNotReject(() => lifecycle.shutdown({ modules: loader }))
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz'])
|
||||
})
|
||||
|
||||
// ── stop(): the admin panel's Disable ──────────────────────────────────────
|
||||
@@ -385,10 +402,9 @@ test('a hook that throws does not stop the ones behind it', async () => {
|
||||
// Phase 4, §2.7.2 decision 3. Phase 2's disable moved a record and left the
|
||||
// module running; the whole point of these tests is that it no longer does.
|
||||
|
||||
test('stop runs that one module\'s onShutdown and leaves the others alone', async () => {
|
||||
test('stop runs the module\'s onShutdown and disables it', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('bbb', { boot: '', shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
@@ -398,10 +414,8 @@ test('stop runs that one module\'s onShutdown and leaves the others alone', asyn
|
||||
const result = await lifecycle.stop('aaa', { modules: loader, model })
|
||||
|
||||
assert.deepEqual(result, { stopped: true, error: null })
|
||||
// Only aaa. This is the difference from shutdown(), which runs everything.
|
||||
assert.deepEqual(noted(file), ['shutdown:aaa'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'disabled')
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'started', 'the other module keeps running')
|
||||
assert.equal(model.rows.get('aaa').state, 'disabled')
|
||||
})
|
||||
|
||||
|
||||
@@ -95,6 +95,17 @@ const oneRoute = (prefix, tier = 'public') => ({
|
||||
|
||||
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
||||
|
||||
/**
|
||||
* One module in a modules directory of its own, loaded. A site runs one module
|
||||
* (W19), so a test that checks several cases loads each case by itself rather
|
||||
* than side by side, where every one after the first would be refused.
|
||||
*/
|
||||
function alone(id, spec, tiers) {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
writeModule(id, spec)
|
||||
return freshLoader(tmpRoot, tiers)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
})
|
||||
@@ -140,23 +151,65 @@ test('load() is once-only, so a second call cannot double-mount', () => {
|
||||
assert.equal(loader.list().length, 1)
|
||||
})
|
||||
|
||||
test('modules load in alphabetical order, since nothing computes a precedence', () => {
|
||||
for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' })
|
||||
// ── One module per site (W19) ──────────────────────────────────────────────
|
||||
|
||||
test('the first module by name holds the site; every other is refused unrequired', () => {
|
||||
// Install refuses a second module, so this is the hand-copied case: Module-uo
|
||||
// and Module-Rust both fill `admin.users.detail`, and the second used to fail
|
||||
// with a slot collision that named neither the rule nor the fix.
|
||||
writeModule('ccc', { server: 'throw new Error("ccc should never be required")' })
|
||||
writeModule('aaa', { server: 'module.exports = () => {}' })
|
||||
writeModule('bbb', { manifest: { version: '2.1.0' }, server: 'throw new Error("bbb should never be required")' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
for (const id of ['bbb', 'ccc']) {
|
||||
const refused = stateOf(loader, id)
|
||||
assert.equal(refused.state, 'startup_failed')
|
||||
assert.equal(refused.stage, 'manifest')
|
||||
assert.match(refused.reason, /already runs the module "aaa", and a site runs one module/)
|
||||
assert.match(refused.reason, new RegExp(`Remove "${id}" or "aaa"`))
|
||||
}
|
||||
// The version still reaches the admin panel, read from module.json alone.
|
||||
assert.equal(stateOf(loader, 'bbb').version, '2.1.0')
|
||||
})
|
||||
|
||||
test('the first module holds the site even when it fails to load', () => {
|
||||
// Which module a site runs is decided by the directory, not by which one
|
||||
// happened to start: a broken first module does not hand the site to the
|
||||
// second, where an operator would be debugging a module they did not mean to run.
|
||||
writeModule('aaa', { server: 'throw new Error("boom")' })
|
||||
writeModule('bbb', { server: 'module.exports = () => {}' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
const bad = stateOf(loader, 'aaa')
|
||||
assert.equal(bad.state, 'startup_failed')
|
||||
assert.match(bad.reason, /boom/)
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already runs the module "aaa"/)
|
||||
})
|
||||
|
||||
test('a second module never mounts, registers or claims a table', () => {
|
||||
const tiers = emptyTiers()
|
||||
writeModule('aaa', oneRoute('/thing'))
|
||||
writeModule('bbb', {
|
||||
...oneRoute('/other'),
|
||||
schema: 'CREATE TABLE IF NOT EXISTS bbb_x (id INT);',
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/thing'), true)
|
||||
assert.equal(claims('/other'), false)
|
||||
assert.deepEqual(loader.fragments().map((f) => f.id), [])
|
||||
assert.deepEqual(loader.bootable().map((b) => b.id), ['aaa'])
|
||||
})
|
||||
|
||||
// ── The failing module fails alone ─────────────────────────────────────────
|
||||
|
||||
test('a module whose entry point throws does not stop the others loading', () => {
|
||||
writeModule('aaa', { server: 'module.exports = () => {}' })
|
||||
writeModule('bbb', { server: 'throw new Error("boom")' })
|
||||
writeModule('ccc', { server: 'module.exports = () => {}' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
||||
|
||||
test('a module whose entry point throws is recorded, and the site still comes up', () => {
|
||||
const loader = alone('bbb', { server: 'throw new Error("boom")' })
|
||||
const bad = stateOf(loader, 'bbb')
|
||||
assert.equal(bad.state, 'startup_failed')
|
||||
assert.match(bad.reason, /boom/)
|
||||
@@ -204,15 +257,6 @@ test('an unknown extension slot is rejected; only core may declare a slot', () =
|
||||
|
||||
// ── Prefix ownership ───────────────────────────────────────────────────────
|
||||
|
||||
test('two modules cannot claim the same prefix; the first one wins', () => {
|
||||
writeModule('aaa', oneRoute('/thing'))
|
||||
writeModule('bbb', oneRoute('/thing'))
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/)
|
||||
})
|
||||
|
||||
test('a module cannot take a prefix core owns, asked of the live tier routers', () => {
|
||||
// Deliberately the REAL routers rather than a hardcoded prefix list. The spike
|
||||
// hardcoded core's ~24 prefixes and they were already stale; asking express
|
||||
@@ -226,41 +270,41 @@ test('a module cannot take a prefix core owns, asked of the live tier routers',
|
||||
}
|
||||
/* eslint-enable global-require */
|
||||
|
||||
writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
|
||||
writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } })
|
||||
assert.match(stateOf(alone('greedy', { manifest: { mounts: { admin: ['/users'] } } }, real), 'greedy').reason, /owned by core/)
|
||||
assert.match(
|
||||
stateOf(alone('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } }, real), 'alsogreedy').reason,
|
||||
/owned by core/,
|
||||
)
|
||||
// Free in every tier core actually mounts, so it must be allowed through.
|
||||
writeModule('polite', oneRoute('/widgets', 'player'))
|
||||
|
||||
const loader = freshLoader(tmpRoot, real)
|
||||
assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
|
||||
assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/)
|
||||
assert.equal(stateOf(loader, 'polite').state, 'registered')
|
||||
assert.equal(stateOf(alone('polite', oneRoute('/widgets', 'player'), real), 'polite').state, 'registered')
|
||||
})
|
||||
|
||||
test('a root-mounted core layer does not make every prefix look taken', () => {
|
||||
// public/index.js ends with `use('/', siteRouter)` and admin with the dashboard
|
||||
// router; both match every path. Counting them would report every prefix as
|
||||
// owned and no module could ever mount.
|
||||
const tiers = emptyTiers()
|
||||
tiers.public.use('/posts', express.Router())
|
||||
tiers.public.use('/', express.Router())
|
||||
const coreTiers = () => {
|
||||
const tiers = emptyTiers()
|
||||
tiers.public.use('/posts', express.Router())
|
||||
tiers.public.use('/', express.Router())
|
||||
return tiers
|
||||
}
|
||||
|
||||
writeModule('fine', oneRoute('/widgets'))
|
||||
writeModule('taken', oneRoute('/posts'))
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
||||
assert.match(stateOf(loader, 'taken').reason, /owned by core/)
|
||||
assert.equal(stateOf(alone('fine', oneRoute('/widgets'), coreTiers()), 'fine').state, 'registered')
|
||||
assert.match(stateOf(alone('taken', oneRoute('/posts'), coreTiers()), 'taken').reason, /owned by core/)
|
||||
})
|
||||
|
||||
test('a prefix with a slash or a parameter in it is rejected', () => {
|
||||
// A prefix that could contain either would let a module reach outside the slot
|
||||
// it was given.
|
||||
writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } })
|
||||
writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/)
|
||||
assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/)
|
||||
assert.match(
|
||||
stateOf(alone('nested', { manifest: { mounts: { public: ['/a/b'] } } }), 'nested').reason,
|
||||
/bad prefix "\/a\/b"/,
|
||||
)
|
||||
assert.match(
|
||||
stateOf(alone('parameterised', { manifest: { mounts: { public: ['/:id'] } } }), 'parameterised').reason,
|
||||
/bad prefix "\/:id"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('registering a prefix that was never declared is rejected', () => {
|
||||
@@ -314,17 +358,6 @@ test('a schema fragment table must carry the module id as a prefix', () => {
|
||||
assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered')
|
||||
})
|
||||
|
||||
test('two modules cannot own the same table either', () => {
|
||||
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' })
|
||||
writeModule('bbb', {
|
||||
manifest: { id: 'bbb' },
|
||||
schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);',
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/)
|
||||
})
|
||||
|
||||
test('declaring a schema without a purge is rejected', () => {
|
||||
const dir = path.join(tmpRoot, 'noway')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
@@ -356,8 +389,7 @@ test('a fragment may only use the four verbs core\'s own schema.sql uses', () =>
|
||||
['wiper', 'DELETE FROM wiper_x;', 'DELETE'],
|
||||
['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'],
|
||||
]) {
|
||||
writeModule(id, { schema: sql })
|
||||
const reason = stateOf(freshLoader(tmpRoot), id).reason
|
||||
const reason = stateOf(alone(id, { schema: sql }), id).reason
|
||||
assert.match(reason, new RegExp(`starts with "${verb}"`))
|
||||
}
|
||||
})
|
||||
@@ -386,24 +418,20 @@ test('a CREATE TABLE without IF NOT EXISTS is rejected', () => {
|
||||
test('a fragment carrying an unreadable file fails the module, not the boot', () => {
|
||||
writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' })
|
||||
fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql'))
|
||||
writeModule('fine', oneRoute('/ok'))
|
||||
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.equal(stateOf(loader, 'missing').state, 'startup_failed')
|
||||
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
||||
})
|
||||
|
||||
test('fragments() lists only registered modules that have one', () => {
|
||||
writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
|
||||
writeModule('nodb', oneRoute('/plain'))
|
||||
writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' })
|
||||
|
||||
const loader = freshLoader(tmpRoot)
|
||||
test('fragments() lists only a registered module that has one', () => {
|
||||
const loader = alone('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
|
||||
const frags = loader.fragments()
|
||||
|
||||
assert.deepEqual(frags.map((f) => f.id), ['withdb'])
|
||||
// An absolute path, so the replay never has to know how a module dir is laid out.
|
||||
assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql'))
|
||||
|
||||
assert.deepEqual(alone('nodb', oneRoute('/plain')).fragments(), [])
|
||||
assert.deepEqual(alone('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' }).fragments(), [])
|
||||
})
|
||||
|
||||
test('fragments() before load() throws, like every other accessor', () => {
|
||||
@@ -417,20 +445,20 @@ test('fragments() before load() throws, like every other accessor', () => {
|
||||
// ── Mounting and the dispatch guard ────────────────────────────────────────
|
||||
|
||||
test('a registered module answers on its prefix; a failed one is simply absent', async () => {
|
||||
writeModule('good', oneRoute('/widgets'))
|
||||
writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' })
|
||||
|
||||
const tiers = emptyTiers()
|
||||
freshLoader(tmpRoot, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
for (const [id, spec, url, status] of [
|
||||
['good', oneRoute('/widgets'), '/public/widgets', 200],
|
||||
// Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
|
||||
// not exist at all (§4.4, left-hand column).
|
||||
assert.equal((await fetch(`${app.url}/public/broken`)).status, 404)
|
||||
} finally {
|
||||
await app.close()
|
||||
['bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' }, '/public/broken', 404],
|
||||
]) {
|
||||
const tiers = emptyTiers()
|
||||
alone(id, spec, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}${url}`)).status, status, id)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -519,8 +547,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
// throws until Phase 7 builds the channel — which is why it has to be in this
|
||||
// list now: a member of a declared version that were absent would make
|
||||
// MODULE_API_VERSION a lie, and this test is what says so.
|
||||
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
|
||||
// at a time. Linking stays core's, and a module takes part through the two
|
||||
// hooks on `api`, never by writing an identity itself.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'activity', 'auth', 'db', 'events', 'express', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
@@ -598,24 +629,20 @@ test('a module with no hooks is bootable, and offers nothing to shut down', () =
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
})
|
||||
|
||||
test('shutdown hooks come back in reverse order, and only for started modules', () => {
|
||||
const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
|
||||
writeModule('aaa', { server: hook })
|
||||
writeModule('bbb', { server: hook })
|
||||
writeModule('ccc', { server: hook })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
test('a shutdown hook comes back only for a started module', () => {
|
||||
const loader = alone('aaa', { server: 'module.exports = (ctx, api) => api.onShutdown(async () => {})' })
|
||||
|
||||
// Nothing has started yet, so there is nothing to tear down.
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
|
||||
loader.setState('aaa', 'started')
|
||||
loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
|
||||
loader.setState('ccc', 'started')
|
||||
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['aaa'])
|
||||
|
||||
// Reverse registration order (§2.5), and `bbb` is absent: a module whose
|
||||
// onBoot threw is mid-way through a warm-up it never finished, and handing it
|
||||
// a half-built world to tear down is worse than not closing cleanly.
|
||||
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
|
||||
// A module whose onBoot threw is mid-way through a warm-up it never finished,
|
||||
// and handing it a half-built world to tear down is worse than not closing
|
||||
// cleanly.
|
||||
loader.setState('aaa', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
})
|
||||
|
||||
// ── Failure stages ─────────────────────────────────────────────────────────
|
||||
@@ -633,11 +660,8 @@ test('a failure is recorded against the §4.3 step that produced it', () => {
|
||||
['f-require', { server: 'throw new Error("boom")' }, 'require'],
|
||||
['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
|
||||
]
|
||||
for (const [id, spec] of cases) writeModule(id, spec)
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
for (const [id, , stage] of cases) {
|
||||
const state = stateOf(loader, id)
|
||||
for (const [id, spec, stage] of cases) {
|
||||
const state = stateOf(alone(id, spec), id)
|
||||
assert.equal(state.state, 'startup_failed', `${id} should have failed`)
|
||||
assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
|
||||
}
|
||||
@@ -665,26 +689,30 @@ test('a module that fails AFTER registering leaves nothing in the registries', (
|
||||
assert.equal(registries.announceLeg('halfway.leg'), null)
|
||||
})
|
||||
|
||||
test('a module colliding with an already-registered name fails alone, unmounted', () => {
|
||||
test('a module colliding with a name core registered fails alone, unmounted', () => {
|
||||
// Core registers through the same staging area a module does, and may name
|
||||
// anything; with one module per site, core is the registrant a module can
|
||||
// collide with. Staged by hand between the reset and the load, which is the
|
||||
// order app.js runs registerCore() and modules.load() in.
|
||||
const tiers = emptyTiers()
|
||||
writeModule('first', {
|
||||
manifest: { mounts: { public: ['/first'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
||||
}`,
|
||||
})
|
||||
writeModule('second', {
|
||||
manifest: { mounts: { public: ['/second'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'second.shared', label: 'Mine' }])
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
registries._reset()
|
||||
const core = registries.stage('core')
|
||||
core.registerNotificationStreams([{ id: 'second.shared', label: 'Shared' }])
|
||||
registries.apply(core.staged)
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'first').state, 'registered')
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "core"/)
|
||||
// Not even the claim that did not collide.
|
||||
assert.equal(registries.isValidStream('second.ok'), false)
|
||||
// And the loser is not mounted at all. Asked of the live router the way the
|
||||
@@ -692,7 +720,6 @@ test('a module colliding with an already-registered name fails alone, unmounted'
|
||||
// produces two (the dispatch guard, then the module's router).
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/first'), true)
|
||||
assert.equal(claims('/second'), false)
|
||||
})
|
||||
|
||||
@@ -753,21 +780,23 @@ test('an entry that escapes the module directory is refused before anything else
|
||||
|
||||
test('an entry that is not a .js file, or is missing, is refused', () => {
|
||||
withChunk('aaa', { entry: 'client/dist/entry.mjs' })
|
||||
assert.match(stateOf(freshLoader(tmpRoot), 'aaa').reason, /must name a \.js file/)
|
||||
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
|
||||
assert.match(stateOf(loader, 'bbb').reason, /is missing/)
|
||||
assert.match(stateOf(freshLoader(tmpRoot), 'bbb').reason, /is missing/)
|
||||
})
|
||||
|
||||
test('a malformed client key is a loud failure, not an ignored setting', () => {
|
||||
writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
|
||||
writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
|
||||
writeModule('ccc', { manifest: { client: {} } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
|
||||
assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
|
||||
assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
|
||||
for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).stage, 'manifest')
|
||||
for (const [id, client, reason] of [
|
||||
['aaa', 'client/dist/entry.js', /client must be an object/],
|
||||
['bbb', { entry: 'x/e.js', chunks: ['a.js'] }, /unknown key "client\.chunks"/],
|
||||
['ccc', {}, /client\.entry must be a path/],
|
||||
]) {
|
||||
const state = stateOf(alone(id, { manifest: { client } }), id)
|
||||
assert.match(state.reason, reason)
|
||||
assert.equal(state.stage, 'manifest')
|
||||
}
|
||||
})
|
||||
|
||||
test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
|
||||
|
||||
@@ -150,23 +150,18 @@ test('fragments are split exactly the way core schema.sql is', async () => {
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('a fragment that throws fails its own module and no one else', async () => {
|
||||
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_x (id INT);' })
|
||||
test('a fragment that throws fails its module, not the boot', async () => {
|
||||
writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (id INT);' })
|
||||
writeModule('ccc', { schema: 'CREATE TABLE IF NOT EXISTS ccc_x (id INT);' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder('bbb_boom')
|
||||
|
||||
// Never throws — this is called on the boot path, between core's schema and
|
||||
// seedDefaults(), and one bad module must not stop the site coming up.
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
// seedDefaults(), and a bad module must not stop the site coming up.
|
||||
await assert.doesNotReject(() => replayFragments({ query: rec.query, modules: loader }))
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/)
|
||||
// The one that matters: the module AFTER the failure still got its tables.
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
||||
assert.equal(rec.ran.length, 3)
|
||||
assert.equal(rec.ran.length, 1)
|
||||
})
|
||||
|
||||
test('a fragment stops at its first failing statement', async () => {
|
||||
@@ -217,15 +212,13 @@ test('a module that failed validation is not replayed at all', async () => {
|
||||
// It is never going to run, so creating its tables would leave an operator
|
||||
// with rows belonging to a module that does not load.
|
||||
writeModule('bad', { schema: 'CREATE TABLE IF NOT EXISTS not_prefixed (id INT);' })
|
||||
writeModule('good', { schema: 'CREATE TABLE IF NOT EXISTS good_x (id INT);' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
assert.equal(stateOf(loader, 'bad').state, 'startup_failed')
|
||||
assert.equal(rec.ran.length, 1)
|
||||
assert.match(rec.ran[0], /good_x/)
|
||||
assert.equal(rec.ran.length, 0)
|
||||
})
|
||||
|
||||
// ── The seed script ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,11 +28,11 @@ test('validateConfig: custom OIDC also needs the endpoint URLs', () => {
|
||||
assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url'])
|
||||
})
|
||||
|
||||
test('listConfigured always includes both built-ins with health', async () => {
|
||||
test('listConfigured always includes every built-in with health', async () => {
|
||||
authProvidersModel.list = async () => [] // no rows yet
|
||||
const out = await registry.listConfigured()
|
||||
const ids = out.map((p) => p.id)
|
||||
assert.deepEqual(ids, ['google', 'discord'])
|
||||
assert.deepEqual(ids, ['google', 'discord', 'steam'])
|
||||
assert.equal(out[0].builtin, true)
|
||||
assert.equal(out[0].enabled, 0)
|
||||
assert.equal(out[0].health.valid, false) // unconfigured
|
||||
|
||||
@@ -131,7 +131,7 @@ test('link mode → identity linked to the acting user, redirect to account', as
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(makeReq(tx), res)
|
||||
|
||||
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' })
|
||||
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com', displayName: null, avatarUrl: null })
|
||||
assert.equal(res.redirectedTo, '/admin/account?linked=google')
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.link')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
|
||||
|
||||
447
server/test/steamSignin.test.js
Normal file
447
server/test/steamSignin.test.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// Sign in with Steam (docs/website/STEAM_SIGNIN.md): the OpenID 2.0 provider's
|
||||
// checks (§3), the callback through the real SSO controller, D394 (key required),
|
||||
// D395 (never signs up) and the module hooks behind D392/D396 (MODULE_API 1.17.0).
|
||||
// Steam itself is a stubbed global fetch; no request leaves the process.
|
||||
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
process.env.APP_BASE_URL = 'https://site.example'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const SteamProvider = require('../src/auth/providers/steam.provider')
|
||||
const registry = require('../src/auth/providers/registry')
|
||||
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
|
||||
const ssoState = require('../src/auth/ssoState')
|
||||
const token = require('../src/auth/token')
|
||||
const identityHooks = require('../src/auth/identityHooks')
|
||||
const loader = require('../src/modules/loader')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const mobileBridge = require('../src/model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const providersCtrl = require('../src/router/v1/admin/authProviders.controller')
|
||||
const accountCtrl = require('../src/router/v1/auth/account.controller')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const STEAM_ID = '76561197960287930'
|
||||
const CALLBACK = 'https://site.example/api/v1/auth/sso/steam/callback'
|
||||
const STEAM_ROW = { id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_id: null, client_secret_enc: 'enc', client_secret: 'WEBAPIKEY' }
|
||||
|
||||
// ── A stand-in for steamcommunity.com and api.steampowered.com ──────────────
|
||||
|
||||
const realFetch = global.fetch
|
||||
let steam // { valid, verifyStatus, verifyThrows, summary, summaryStatus, calls }
|
||||
function installSteam(overrides = {}) {
|
||||
steam = {
|
||||
valid: true,
|
||||
verifyStatus: 200,
|
||||
verifyThrows: false,
|
||||
summary: { steamid: STEAM_ID, personaname: 'CoolGuy99', avatarfull: 'https://avatars.steamstatic.com/abc_full.jpg' },
|
||||
summaryStatus: 200,
|
||||
calls: [],
|
||||
...overrides,
|
||||
}
|
||||
global.fetch = async (url, init = {}) => {
|
||||
const href = String(url)
|
||||
steam.calls.push({ href, init })
|
||||
if (href === SteamProvider.OP_ENDPOINT) {
|
||||
if (steam.verifyThrows) throw new Error('ECONNRESET')
|
||||
const body = new URLSearchParams(init.body)
|
||||
steam.lastVerifyBody = body
|
||||
return {
|
||||
ok: steam.verifyStatus === 200,
|
||||
status: steam.verifyStatus,
|
||||
text: async () => `ns:http://specs.openid.net/auth/2.0\nis_valid:${steam.valid}\n`,
|
||||
}
|
||||
}
|
||||
if (href.startsWith('https://api.steampowered.com/')) {
|
||||
return {
|
||||
ok: steam.summaryStatus === 200,
|
||||
status: steam.summaryStatus,
|
||||
json: async () => ({ response: { players: steam.summary ? [steam.summary] : [] } }),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected fetch ${href}`)
|
||||
}
|
||||
}
|
||||
afterEach(() => { global.fetch = realFetch })
|
||||
|
||||
// What Steam appends to our return_to after a successful sign-in.
|
||||
function assertion(state, overrides = {}) {
|
||||
const claimed = `https://steamcommunity.com/openid/id/${STEAM_ID}`
|
||||
return {
|
||||
state,
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'id_res',
|
||||
'openid.op_endpoint': SteamProvider.OP_ENDPOINT,
|
||||
'openid.claimed_id': claimed,
|
||||
'openid.identity': claimed,
|
||||
'openid.return_to': SteamProvider.returnToFor(CALLBACK, state),
|
||||
'openid.response_nonce': '2026-10-19T12:00:00Zabc',
|
||||
'openid.assoc_handle': '1234567890',
|
||||
'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
|
||||
'openid.sig': 'c2lnbmF0dXJl',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── The provider (§3) ───────────────────────────────────────────────────────
|
||||
|
||||
test('the authorization URL is an OpenID 2.0 checkid_setup with our state inside return_to', () => {
|
||||
const url = new URL(new SteamProvider({}).getAuthorizationUrl('nonce-1', { redirectUri: CALLBACK }))
|
||||
assert.equal(url.origin + url.pathname, SteamProvider.OP_ENDPOINT)
|
||||
assert.equal(url.searchParams.get('openid.mode'), 'checkid_setup')
|
||||
assert.equal(url.searchParams.get('openid.return_to'), `${CALLBACK}?state=nonce-1`)
|
||||
assert.equal(url.searchParams.get('openid.realm'), 'https://site.example')
|
||||
assert.equal(url.searchParams.get('openid.claimed_id'), 'http://specs.openid.net/auth/2.0/identifier_select')
|
||||
assert.equal(url.searchParams.get('client_id'), null, 'Steam has no client id')
|
||||
})
|
||||
|
||||
test('parseClaimedId accepts only a 17-digit id on steamcommunity.com', () => {
|
||||
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}`), STEAM_ID)
|
||||
assert.equal(SteamProvider.parseClaimedId('http://steamcommunity.com/openid/id/76561197960287930'), null)
|
||||
assert.equal(SteamProvider.parseClaimedId('https://steamcommunity.com/openid/id/123'), null)
|
||||
assert.equal(SteamProvider.parseClaimedId('https://evil.example/openid/id/76561197960287930'), null)
|
||||
assert.equal(SteamProvider.parseClaimedId(`https://steamcommunity.com/openid/id/${STEAM_ID}/x`), null)
|
||||
assert.equal(SteamProvider.parseClaimedId(undefined), null)
|
||||
})
|
||||
|
||||
test('a valid assertion is verified with Steam and yields the id, persona name and avatar', async () => {
|
||||
installSteam()
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
||||
assert.deepEqual(profile, {
|
||||
subject: STEAM_ID, email: null, emailVerified: false, name: 'CoolGuy99',
|
||||
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
|
||||
})
|
||||
// The verification echoed every openid field back with mode check_authentication,
|
||||
// and did not forward our own `state`.
|
||||
assert.equal(steam.lastVerifyBody.get('openid.mode'), 'check_authentication')
|
||||
assert.equal(steam.lastVerifyBody.get('openid.sig'), 'c2lnbmF0dXJl')
|
||||
assert.equal(steam.lastVerifyBody.get('state'), null)
|
||||
})
|
||||
|
||||
for (const [label, overrides, state] of [
|
||||
['a wrong op_endpoint', { 'openid.op_endpoint': 'https://evil.example/openid/login' }, 's1'],
|
||||
['a return_to that is not ours', { 'openid.return_to': 'https://evil.example/cb?state=s1' }, 's1'],
|
||||
['a claimed id that is not a Steam id', { 'openid.claimed_id': 'https://steamcommunity.com/openid/id/1', 'openid.identity': 'https://steamcommunity.com/openid/id/1' }, 's1'],
|
||||
['an identity that differs from the claimed id', { 'openid.identity': 'https://steamcommunity.com/openid/id/76561197960287931' }, 's1'],
|
||||
['mode cancel', { 'openid.mode': 'cancel' }, 's1'],
|
||||
]) {
|
||||
test(`${label} is refused before Steam is even asked`, async () => {
|
||||
installSteam()
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
await assert.rejects(
|
||||
p.handleCallback({ query: assertion(state, overrides), redirectUri: CALLBACK, state }),
|
||||
SteamProvider.SteamAssertionError,
|
||||
)
|
||||
assert.equal(steam.calls.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('an assertion replayed into another sign-in fails: its return_to carries the old state', async () => {
|
||||
installSteam()
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
// Captured from a sign-in whose state was s-old, replayed against a tx whose state is s-new.
|
||||
const replay = { ...assertion('s-old'), state: 's-new' }
|
||||
await assert.rejects(p.handleCallback({ query: replay, redirectUri: CALLBACK, state: 's-new' }), { reason: 'return_to' })
|
||||
})
|
||||
|
||||
test('check_authentication saying is_valid:false refuses the sign-in', async () => {
|
||||
installSteam({ valid: false })
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
||||
})
|
||||
|
||||
test('Steam unreachable for verification is a refusal, never a pass', async () => {
|
||||
installSteam({ verifyThrows: true })
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
||||
installSteam({ verifyStatus: 503 })
|
||||
await assert.rejects(p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' }), { reason: 'not_valid' })
|
||||
})
|
||||
|
||||
test('a failed Web API lookup still signs in, with the bare id (D394)', async () => {
|
||||
installSteam({ summaryStatus: 403 })
|
||||
const p = new SteamProvider({ clientSecret: 'WEBAPIKEY' })
|
||||
const profile = await p.handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
||||
assert.equal(profile.subject, STEAM_ID)
|
||||
assert.equal(profile.displayName, null)
|
||||
assert.equal(profile.avatarUrl, null)
|
||||
})
|
||||
|
||||
test('an avatar that is not on a Steam CDN is dropped', async () => {
|
||||
installSteam({ summary: { steamid: STEAM_ID, personaname: 'X', avatarfull: 'https://evil.example/a.jpg' } })
|
||||
const profile = await new SteamProvider({ clientSecret: 'K' }).handleCallback({ query: assertion('s1'), redirectUri: CALLBACK, state: 's1' })
|
||||
assert.equal(profile.displayName, 'X')
|
||||
assert.equal(profile.avatarUrl, null)
|
||||
})
|
||||
|
||||
// ── Registry and Admin → Auth (D394) ────────────────────────────────────────
|
||||
|
||||
test('Steam is a built-in that needs only a Web API key', () => {
|
||||
assert.ok(registry.isBuiltin('steam'))
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'steam' }), { valid: false, missing: ['api_key'] })
|
||||
assert.deepEqual(registry.validateConfig({ kind: 'steam', client_secret_enc: 'e' }), { valid: true, missing: [] })
|
||||
assert.equal(registry.neverProvisions('steam'), true)
|
||||
assert.equal(registry.neverProvisions('google'), false)
|
||||
})
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
|
||||
status(c) { this.statusCode = c; return this },
|
||||
json(b) { this.body = b; return this },
|
||||
redirect(u) { this.redirectedTo = u; return this },
|
||||
cookie(n, v) { this.cookies[n] = v; return this },
|
||||
clearCookie(n) { this.cleared.push(n); return this },
|
||||
}
|
||||
}
|
||||
|
||||
test('enabling Steam with no key is refused with a sentence; with one it saves', async () => {
|
||||
let saved = null
|
||||
authProviders.get = async () => null
|
||||
authProviders.save = async (id, fields) => { saved = { id, ...fields }; return { id, kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' } }
|
||||
activity.log = async () => {}
|
||||
|
||||
const refused = mockRes()
|
||||
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, refused)
|
||||
assert.equal(refused.statusCode, 400)
|
||||
assert.match(refused.body.message, /Steam Web API key/)
|
||||
assert.equal(saved, null)
|
||||
|
||||
const ok = mockRes()
|
||||
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true, secret: 'WEBAPIKEY', clientId: 'ignored' }, user: { username: 'admin' } }, ok)
|
||||
assert.equal(ok.statusCode, 200)
|
||||
assert.equal(saved.kind, 'steam')
|
||||
assert.equal(saved.secret, 'WEBAPIKEY')
|
||||
assert.equal(saved.clientId, undefined, 'Steam keeps no client id')
|
||||
assert.equal(ok.body.hasSecret, true)
|
||||
})
|
||||
|
||||
test('re-enabling Steam with a key already stored needs no new one', async () => {
|
||||
authProviders.get = async () => ({ id: 'steam', kind: 'steam', client_secret_enc: 'e' })
|
||||
authProviders.save = async () => ({ id: 'steam', kind: 'steam', name: 'Steam', enabled: 1, client_secret_enc: 'e' })
|
||||
activity.log = async () => {}
|
||||
const res = mockRes()
|
||||
await providersCtrl.update({ params: { id: 'steam' }, body: { enabled: true }, user: { username: 'admin' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
})
|
||||
|
||||
// ── The callback through the real controller ────────────────────────────────
|
||||
|
||||
let logged
|
||||
let linkArgs
|
||||
let refreshed
|
||||
let created
|
||||
let hooks
|
||||
beforeEach(() => {
|
||||
logged = []
|
||||
linkArgs = null
|
||||
refreshed = null
|
||||
created = null
|
||||
hooks = { onIdentityLinking: [], onIdentityLinked: [] }
|
||||
activity.log = async (evt) => { logged.push(evt) }
|
||||
authProviders.getWithSecret = async () => ({ ...STEAM_ROW })
|
||||
registry.instantiate = (row) => new SteamProvider({ ...row, clientSecret: row.client_secret })
|
||||
userIdentities.findByProviderSubject = async () => null
|
||||
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
||||
userIdentities.refreshProfile = async (id, p) => { refreshed = { id, ...p }; return 1 }
|
||||
users.getById = async (id) => ({ id, username: 'alice', role: 'player', status: 'active' })
|
||||
users.recordLogin = async () => {}
|
||||
users.createUser = async (args) => { created = args; return { id: 99, ...args } }
|
||||
settings.getRegistrationMode = async () => 'both' // SSO sign-up ON: Steam must still refuse
|
||||
loader.identityHooks = (name) => hooks[name]
|
||||
installSteam()
|
||||
})
|
||||
|
||||
function steamReq(tx, overrides = {}) {
|
||||
return {
|
||||
params: { provider: 'steam' },
|
||||
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
|
||||
query: assertion(tx.nonce, overrides),
|
||||
ip: '127.0.0.1', protocol: 'https', get: () => 'site.example', headers: {},
|
||||
}
|
||||
}
|
||||
|
||||
test('a linked Steam account signs in through the one session service, and its name is refreshed', async () => {
|
||||
userIdentities.findByProviderSubject = async (provider, subject) =>
|
||||
(provider === 'steam' && subject === STEAM_ID ? { id: 3, user_id: 7, provider: 'steam' } : null)
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set')
|
||||
assert.equal(res.redirectedTo, '/account')
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.login')
|
||||
assert.deepEqual(refreshed, { id: 3, displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg' })
|
||||
})
|
||||
|
||||
test('an unlinked Steam id signs nobody in and creates nobody, even with SSO sign-up on (D395)', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login', returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account/login?sso_error=not_linked')
|
||||
assert.equal(created, null)
|
||||
assert.equal(linkArgs, null)
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||
})
|
||||
|
||||
test('the same refusal holds on the mobile bridge (D395)', async () => {
|
||||
mobileBridge.getSession = async () => ({
|
||||
session_id: 'm1', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm1' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, 'runicgateway://sso?error=not_linked&state=app-state')
|
||||
assert.equal(created, null)
|
||||
})
|
||||
|
||||
test('a linked Steam account signs in to the app through the mobile bridge (M9)', async () => {
|
||||
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7, provider: 'steam' })
|
||||
mobileBridge.getSession = async () => ({
|
||||
session_id: 'm2', status: 'pending', redirect_uri: 'runicgateway://sso', state: 'app-state',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
mobileBridge.issueAuthCode = async () => ({ code: 'one-time-code' })
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'mobile', mobileSessionId: 'm2' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, 'runicgateway://sso?code=one-time-code&state=app-state')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'the app gets a code, never a cookie')
|
||||
})
|
||||
|
||||
test('a forged callback (Steam says is_valid:false) is a refused sign-in, not a server error', async () => {
|
||||
installSteam({ valid: false })
|
||||
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 7 })
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
|
||||
})
|
||||
|
||||
test('cancelling on steamcommunity.com reads as denied', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback({ ...steamReq(tx), query: { state: tx.nonce, 'openid.mode': 'cancel' } }, res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=denied')
|
||||
assert.equal(steam.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a callback with a state that is not the tx is refused before Steam is asked', async () => {
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'login' })
|
||||
const res = mockRes()
|
||||
const req = steamReq(tx)
|
||||
req.query.state = 'tampered'
|
||||
await ssoCtrl.callback(req, res)
|
||||
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
|
||||
assert.equal(steam.calls.length, 0)
|
||||
})
|
||||
|
||||
// ── Linking, and the module hooks (MODULE_API 1.17.0) ───────────────────────
|
||||
|
||||
test('linking Steam stores the id with its name and avatar, then tells the modules once (D392)', async () => {
|
||||
const told = []
|
||||
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.deepEqual(linkArgs, {
|
||||
userId: 5, provider: 'steam', subject: STEAM_ID, email: null,
|
||||
displayName: 'CoolGuy99', avatarUrl: 'https://avatars.steamstatic.com/abc_full.jpg',
|
||||
})
|
||||
assert.equal(res.redirectedTo, '/account?linked=steam')
|
||||
assert.deepEqual(told, [{ userId: 5, provider: 'steam', subject: STEAM_ID, displayName: 'CoolGuy99' }])
|
||||
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
|
||||
})
|
||||
|
||||
test('a module refusing a link writes nothing and the player reads "in use" (D396)', async () => {
|
||||
const told = []
|
||||
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => ({ ok: false, reason: 'in_use', message: 'held by bob' }) }]
|
||||
hooks.onIdentityLinked = [{ id: 'rust', hook: async (link) => { told.push(link) } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?link_error=in_use')
|
||||
assert.equal(linkArgs, null)
|
||||
assert.deepEqual(told, [])
|
||||
assert.equal(logged.at(-1).action, 'auth.sso.link.refused')
|
||||
assert.doesNotMatch(res.redirectedTo, /bob/, 'a module\'s own words never reach the URL')
|
||||
})
|
||||
|
||||
test('a module that throws or stalls refuses the link (fails closed)', async () => {
|
||||
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { throw new Error('db down') } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?link_error=refused')
|
||||
assert.equal(linkArgs, null)
|
||||
})
|
||||
|
||||
test('a linked hook that fails never undoes the link', async () => {
|
||||
hooks.onIdentityLinked = [{ id: 'rust', hook: async () => { throw new Error('boom') } }]
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?linked=steam')
|
||||
assert.ok(linkArgs)
|
||||
})
|
||||
|
||||
test('linking a Steam account already yours refreshes it and asks no module', async () => {
|
||||
let asked = false
|
||||
hooks.onIdentityLinking = [{ id: 'rust', hook: async () => { asked = true; return { ok: true } } }]
|
||||
userIdentities.findByProviderSubject = async () => ({ id: 3, user_id: 5, provider: 'steam' })
|
||||
const tx = ssoState.createTx({ provider: 'steam', mode: 'link', linkUserId: 5, returnTo: '/account' })
|
||||
const res = mockRes()
|
||||
await ssoCtrl.callback(steamReq(tx), res)
|
||||
assert.equal(res.redirectedTo, '/account?linked=steam')
|
||||
assert.equal(asked, false)
|
||||
assert.equal(linkArgs, null)
|
||||
assert.equal(refreshed.id, 3)
|
||||
})
|
||||
|
||||
test('checkLink stops at the first refusal and hands a hook a frozen copy', async () => {
|
||||
let seen
|
||||
hooks.onIdentityLinking = [
|
||||
{ id: 'a', hook: async (link) => { seen = link; return { ok: true } } },
|
||||
{ id: 'b', hook: async () => ({ ok: false, reason: 'anything else' }) },
|
||||
{ id: 'c', hook: async () => { throw new Error('never reached') } },
|
||||
]
|
||||
const answer = await identityHooks.checkLink({ userId: 1, provider: 'steam', subject: STEAM_ID, extra: 'x' })
|
||||
assert.deepEqual(answer, { ok: false, reason: 'refused', module: 'b' })
|
||||
assert.ok(Object.isFrozen(seen))
|
||||
assert.deepEqual(Object.keys(seen), ['userId', 'provider', 'subject'])
|
||||
})
|
||||
|
||||
test('with no module hooks a link is checked exactly as before', async () => {
|
||||
assert.deepEqual(await identityHooks.checkLink({ userId: 1, provider: 'google', subject: 's' }), { ok: true })
|
||||
})
|
||||
|
||||
// ── Unlinking one of several (D393 leaves module links alone) ───────────────
|
||||
|
||||
test('unlink with ?id= removes that one identity only', async () => {
|
||||
let args
|
||||
userIdentities.unlink = async (...a) => { args = a; return 1 }
|
||||
const res = mockRes()
|
||||
await accountCtrl.unlinkIdentity({ params: { provider: 'steam' }, query: { id: '12' }, user: { id: 5 } }, res)
|
||||
assert.deepEqual(args, [5, 'steam', 12])
|
||||
assert.deepEqual(res.body, { unlinked: true })
|
||||
})
|
||||
|
||||
test('unlink without an id removes every identity of the provider, as before', async () => {
|
||||
let args
|
||||
userIdentities.unlink = async (...a) => { args = a; return 2 }
|
||||
const res = mockRes()
|
||||
await accountCtrl.unlinkIdentity({ params: { provider: 'google' }, query: {}, user: { id: 5 } }, res)
|
||||
assert.deepEqual(args, [5, 'google', null])
|
||||
})
|
||||
Reference in New Issue
Block a user