Close Stoat registration by default, behind CFG_STOAT_INVITE_ONLY

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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-20 01:05:51 +01:00
parent a1290b47a3
commit 29fa8e8a60
3 changed files with 65 additions and 3 deletions

View File

@ -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)"

View File

@ -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" <<EOF
# Generated by LibrePortal at install time. Secrets live in secrets.env, not
# here. Reinstalling the app rewrites this file — put custom configuration in a
@ -133,6 +138,14 @@ host = "rabbit"
port = 5672
username = "stoat"
password = "${rabbit_pass}"
# Whether a stranger who reaches this URL can sign themselves up. Upstream
# defaults to open, with no captcha and no email verification to slow it down.
# The section name is load-bearing and not guessable: invite_only under
# [features] or [api.security.authifier] is accepted silently and does nothing —
# the API keeps reporting invite_only=false — so only [api.registration] here.
[api.registration]
invite_only = ${invite_only}
EOF
if [[ -n "$video_enabled" ]]; then
@ -417,9 +430,15 @@ stoat_install_post()
# account does not close registration. Stoat ships invite_only=false with
# no captcha and no email verification, so anyone who can reach the URL
# can still make their own account.
echo " Registration is still OPEN — anyone who can reach that URL can"
echo " sign up. Keep the port off the internet, or set invite_only in"
echo " Revolt.toml, if that is not what you want."
if [[ "${CFG_STOAT_INVITE_ONLY:-true}" == "false" ]]; then
echo " Registration is OPEN — anyone who can reach that URL can sign"
echo " up, with no captcha and no email check. Set"
echo " CFG_STOAT_INVITE_ONLY=true to close it."
else
echo " Registration is closed. Add people with the Create User"
echo " Account tool on this app's Tools tab, which issues its own"
echo " single-use invite."
fi
else
echo " Open ${base} and create an account."
echo ""

View File

@ -80,6 +80,21 @@ CFG_STOAT_MINIO_PASSWORD_1=RANDOMIZEDPASSWORD2
CFG_STOAT_ADMIN_EMAIL=admin@stoat.local
CFG_STOAT_ADMIN_USERNAME=administrator
CFG_STOAT_ADMIN_PASSWORD_1=RANDOMIZEDPASSWORD3
# INVITE_ONLY = if true, nobody can sign up from the web UI; accounts are added
# with the Create User Account tool.
#
# Defaults CLOSED, unlike Vaultwarden's SIGNUPS_ALLOWED=true, because the reason
# that one is open does not apply here: Vaultwarden has to let you register to
# get in at all, whereas the owner account above is created for you. Matrix takes
# the same closed default for the same shape of app — an open chat server with no
# captcha and no email verification is a standing invitation once it is reachable.
# Closed is not a dead end: the Create User Account tool mints its own single-use
# invite, so it keeps working either way.
#
# Written into Revolt.toml at install, so changing it needs an Install/Reinstall
# to take effect — a Reload only restarts containers and leaves the generated
# config as it was.
CFG_STOAT_INVITE_ONLY=true
#
# =============================================================================
# METADATA