First-time setup: the first login made while no administrator exists becomes it
Build / Build (push) Successful in 10m49s
Deploy / privapub.thepra.dev (push) Successful in 14m0s

Sign-up stays closed (invitations only), so a new server's first login could only be made over SSH with admin
create-root, and an invitation needs a group, which needs a login. While no administrator exists, GET
/clientapi/user/setup says so and POST takes a SetupForm (login, password, first persona): the login is made the
administrator with its persona, and setup closes for good (owner decision 2026-10-16: first come, first served, no setup
code).

ServerSetup is one document with a fixed id, so the first request to insert it wins and two setups at once make one
administrator. A setup that fails before its login is made gives the claim back; one that named an administrator never
does, whoever is demoted or deleted later, and the CLI stays the way back. admin promote and create-root --admin seal it
too, migration _018 seals a server that already had an administrator, and no backup holds it nor does a restore drop it,
since a restore tombstones an administrator made after the backup. Public sign-up, NodeInfo and the instance API are
unchanged. RootUser.SetAdmin is the one place an administrator is made (with isModerator, which IsAdminPolicy asks for),
and the invitation endpoints share InsertPersona with setup.

Tests: ServerSetupTests on the invitations-only host (setup, the race, failed setups, sign-up still closed, the CLI's
seal) and the migration; the whole suite passes (962). In the pasture, decePub's setup and invitation pages pass end to
end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-16 23:21:41 +02:00
1 parent 088c54ddf3
commit 8156c1927c
12 files changed
+396 -27

No files matched your search

@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Resources;
using PrivaPub.ClientModels.Group;
@@ -135,13 +137,7 @@ namespace PrivaPub.Controllers.ClientToServer
return StatusCode(result.StatusCode, result);
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{
RootId = user.ID,
Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
UserName = avatarUserName,
Biography = string.Empty
});
var avatar = await InsertPersona(user.ID, avatarUserName, signUpForm.AvatarName);
if (!avatar.IsValid)
return StatusCode(avatar.StatusCode, avatar);
@@ -190,13 +186,7 @@ namespace PrivaPub.Controllers.ClientToServer
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
if (personaProblem != default)
return BadRequest(new WebResult().Invalidate(personaProblem));
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
{
RootId = user.ID,
Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
UserName = avatarUserName,
Biography = string.Empty
});
var inserted = await InsertPersona(user.ID, avatarUserName, loginForm.AvatarName);
if (!inserted.IsValid)
return StatusCode(inserted.StatusCode, inserted);
avatar = (ViewAvatar)inserted.Data;
@@ -220,6 +210,77 @@ namespace PrivaPub.Controllers.ClientToServer
}
}
[HttpGet, Route("/clientapi/user/setup"), AllowAnonymous]
public async Task<IActionResult> Setup(CancellationToken token) =>
Ok(new ViewServerSetup { IsPending = await ServerSetup.IsPending(token) });
// The server's first login, made while it has no administrator (owner decision 2026-10-16: first come, first served):
// it is made the administrator, with its first persona, and setup closes for good. Sign-up stays closed to everyone
// else (Registrations), so NodeInfo and the instance API don't change.
[HttpPost, Route("/clientapi/user/setup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
public async Task<IActionResult> SetUp(SetupForm setupForm, CancellationToken token)
{
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
var result = new WebResult();
if (!ModelState.IsValid)
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
var claimed = false;
var adminId = default(string);
try
{
if (!await ServerSetup.IsPending(token))
return SetUpAlready();
var avatarUserName = setupForm.AvatarUserName?.Trim().ToLowerInvariant();
var personaProblem = await PersonaProblem(avatarUserName, setupForm.UserName, token);
if (personaProblem != default)
return BadRequest(result.Invalidate(personaProblem));
claimed = await ServerSetup.Claim(token);
if (!claimed)
return SetUpAlready();
result = await UsersService.SignUpAsync(setupForm);
if (!result.IsValid)
{
await ServerSetup.Release();
return StatusCode(result.StatusCode, result);
}
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
user.SetAdmin(true);
await DB.Default.SaveAsync(user, CancellationToken.None);
adminId = user.ID;
await ServerSetup.Claimed(adminId, CancellationToken.None);
// set up from here, whatever happens to the persona: it can be made in the client's settings
var avatar = await InsertPersona(user.ID, avatarUserName, setupForm.AvatarName);
if (!avatar.IsValid)
return StatusCode(avatar.StatusCode, avatar);
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(User)}.{nameof(SetUp)}()");
if (claimed && adminId == default)
await ServerSetup.Release();
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
}
}
IActionResult SetUpAlready() =>
StatusCode(StatusCodes.Status403Forbidden,
new WebResult().Invalidate(Localizer["This server is already set up: joining it takes a group invitation."], StatusCodes.Status403Forbidden));
Task<WebResult> InsertPersona(string rootId, string avatarUserName, string avatarName) =>
AvatarUsersService.InsertAvatar(new InsertAvatarForm
{
RootId = rootId,
Name = string.IsNullOrWhiteSpace(avatarName) ? avatarUserName : avatarName.Trim(),
UserName = avatarUserName,
Biography = string.Empty
});
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarUserName))