From 29fa8e8a604320ff83e73d644e18a739d53f9196 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 20 Aug 2026 01:05:51 +0100 Subject: [PATCH] Close Stoat registration by default, behind CFG_STOAT_INVITE_ONLY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning the owner account did not stop anyone else signing up. Stoat ships open, with no captcha and no email verification, so a reachable instance still accepted walk-in registrations. Made a config option rather than hardcoded, because running an open community server is legitimate — but defaulting CLOSED, which is the opposite of the other registration toggle in the tree. Vaultwarden's SIGNUPS_ALLOWED defaults true for a reason that does not apply here: it has to let you register to get in at all, whereas Stoat's owner account is now created for you. Matrix's ENABLE_REGISTRATION already defaults false for the same shape of app. The section name is load-bearing and not guessable. invite_only under [features] or [api.security.authifier] is accepted in silence and does nothing — the API keeps reporting invite_only=false — so it goes under [api.registration], which was found by testing all three against a running instance. Anything but an explicit "false" closes registration, so a blank or misspelled value fails safe. Closing it broke LibrePortal's own tooling, which is the part worth noting: the API answers MissingInvite to create_account too. So account creation now mints a single-use invite and retries when it sees that. Reactive rather than reading the config, so it follows the instance's actual state — someone who edits Revolt.toml by hand gets the same behaviour. Stoat stamps the invite used/claimed_by as it consumes it, and a failed create deletes it, so no reusable invite is left behind; verified that the collection holds zero unused invites after two creations. Verified end to end: a fresh install reports invite_only=true, a walk-in signup is refused with MissingInvite, and the Create User Account tool still succeeds. Flipping the config to false and reinstalling flips the API to open, and the provisioning guard correctly reports "already has accounts" instead of trying to claim a second owner. The value is baked into Revolt.toml at install, so changing it needs a reinstall rather than a reload — now said in the config comment. Unrelated flake seen once during testing and not reproduced: an install left stoat-rabbit with no IP row, so the compose died on a literal IP_DATA_3. A straight uninstall/reinstall allocated all 16 cleanly. Untouched here — it is in the IP allocator, not this change. Co-Authored-By: Claude Opus 5 --- containers/stoat/scripts/stoat_auth.sh | 28 +++++++++++++++++++ .../stoat/scripts/stoat_install_hooks.sh | 25 +++++++++++++++-- containers/stoat/stoat.config | 15 ++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/containers/stoat/scripts/stoat_auth.sh b/containers/stoat/scripts/stoat_auth.sh index b649522..0aa3caa 100644 --- a/containers/stoat/scripts/stoat_auth.sh +++ b/containers/stoat/scripts/stoat_auth.sh @@ -65,6 +65,34 @@ _stoatCreateAccount() { body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass")}" out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \ -H 'Content-Type: application/json' -d "$body" 2>&1) + + # On a closed instance the API answers MissingInvite. Rather than refuse, mint + # one: an admin creating an account here IS the authorisation, and requiring + # them to go and generate an invite by hand first would make CFG_STOAT_INVITE_ONLY + # a switch that breaks LibrePortal's own tooling. + # + # Reactive rather than reading the config, so this follows the instance's ACTUAL + # state — someone who edits Revolt.toml by hand, or flips it after install, gets + # the same behaviour without LibrePortal having to be told. + # + # The invite is single-use and consumed by this create: Stoat stamps it + # used/claimed_by, so it cannot become a spare key left under the mat. + if [[ "$out" == *MissingInvite* ]]; then + local code + code=$(_stoatMongo 'const c = "LP" + Math.random().toString(36).slice(2, 10).toUpperCase(); +db.account_invites.insertOne({_id: c, used: false}); +print(c);' | tr -dc 'A-Z0-9') + if [[ -z "$code" ]]; then + isError "Stoat is invite-only and an invite could not be created." + return 1 + fi + body="{\"email\":$(_stoatJson "$email"),\"password\":$(_stoatJson "$pass"),\"invite\":$(_stoatJson "$code")}" + out=$(runFileOp curl -sS --max-time 20 -X POST "${api}/auth/account/create" \ + -H 'Content-Type: application/json' -d "$body" 2>&1) + # Do not leave an unused invite behind if the create still failed. + [[ -n "$out" ]] && _stoatMongo "db.account_invites.deleteOne({_id: $(_stoatJson "$code"), used: false})" >/dev/null 2>&1 + fi + # A successful create returns 204 with no body; anything printed is an error. if [[ -n "$out" && "$out" != *'"result"'* ]]; then isError "Stoat account create failed: $(printf '%s' "$out" | tr -d '\n' | head -c 200)" diff --git a/containers/stoat/scripts/stoat_install_hooks.sh b/containers/stoat/scripts/stoat_install_hooks.sh index badc2ff..2dec0e1 100644 --- a/containers/stoat/scripts/stoat_install_hooks.sh +++ b/containers/stoat/scripts/stoat_install_hooks.sh @@ -103,6 +103,11 @@ EOF printf '{"api":"%s/api"}' "$base" | runFileWrite "$app_dir/stoat.json" + # Anything but an explicit "false" closes registration: a blank or misspelled + # value should fail safe, not silently open the instance to the internet. + local invite_only="true" + [[ "${CFG_STOAT_INVITE_ONLY:-true}" == "false" ]] && invite_only="false" + runFileWrite "$app_dir/Revolt.toml" <