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

@@ -40,7 +40,8 @@ namespace PrivaPub.Infrastructure.Backup
[nameof(MaintenanceLock)] = "who is backing up or restoring now",
["AppConfiguration"] = "the configuration's copy holds the SMTP password, and is made again from appsettings at boot",
[nameof(RestoreRecord)] = "what restores did outlives what they restore",
["PersonaArchive"] = "personas' archives being made or imported belong to the moment"
["PersonaArchive"] = "personas' archives being made or imported belong to the moment",
["ServerSetup"] = "the server is set up once: a restore never opens its setup again"
};
static readonly JsonWriterSettings Json = new() { OutputMode = JsonOutputMode.CanonicalExtendedJson, Indent = false };
+6 -7
View File
@@ -4,6 +4,7 @@ using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Models;
using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.Services.ClientToServer.Private;
@@ -194,19 +195,17 @@ namespace PrivaPub.Infrastructure.Cli
return 1;
}
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
if (promote)
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
if (!user.Policies.Contains(Policies.IsUser))
user.Policies.Add(Policies.IsUser);
user.UpdatedAt = DateTime.UtcNow;
user.SetAdmin(promote);
await DB.Default.SaveAsync(user);
if (promote)
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
return 0;
}
// With sign-up closed this is how the first root is made; group invitations make the rest.
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
// make the rest.
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
{
var form = new LoginForm { UserName = login, Password = password?.Trim() };
@@ -0,0 +1,23 @@
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Models;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
// the client's setup is open only while no administrator exists (ServerSetup): a server that already has one, made with
// the CLI before setup existed, is set up
public class _018_a_server_with_an_administrator_is_set_up : IMigration
{
public async Task UpgradeAsync()
{
var admin = await DB.Default.Find<RootUser>()
.Match(u => u.Policies.Contains(Policies.IsAdmin))
.Sort(u => u.CreatedAt, Order.Ascending)
.ExecuteFirstAsync();
if (admin != default)
await ServerSetup.Seal(admin.ID, default);
}
}
}