The admin CLI moves a persona to another login and deletes a login; the deploy no longer signs in
Build / Build (push) Successful in 10m53s
Deploy / privapub.thepra.dev (push) Successful in 10m5s

The owner took @thepra, which the deploy had made and kept under its own login deploy-smoke for its signed-in check, for
the administrator's login (owner decision 2026-10-17). No persona could change login, so:
- admin move-persona <persona> <root> re-points the persona's link first, so the old login can no longer exchange a token
  for it, then ends its Mastodon tokens and authorizations. Its actor, posts and followers stay; nobody outside is told,
  since nothing they hold changes.
- admin delete-root <root> deletes a login as the client deletes its own (RootRemoval): every persona it still has goes
  with it, announced deleted, and its sessions end.
- admin smoke and the deploy's signed-in step are gone, since the step would make deploy-smoke again and fail to make
  @thepra under it. The deploy keeps its anonymous checks; tools/smoke/oauth.sh stays for the pasture's privapub_token.

Tests: move-persona (the persona listed under its new login only, its old token refused, unknown persona or login exit 1)
and delete-root (the login and its persona deleted, the login refused); the whole suite passes (963).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 00:12:56 +02:00
1 parent 8156c1927c
commit c42229f78c
6 files changed
+113 -94

No files matched your search

+61 -56
View File
@@ -1,29 +1,26 @@
using MongoDB.Entities;
using OpenIddict.Abstractions;
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;
using PrivaPub.StaticServices;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
namespace PrivaPub.Infrastructure.Cli
{
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
public static class AdminCommands
{
public const string SmokeLogin = "deploy-smoke";
const string Usage = """
usage: PrivaPub admin promote|demote <root>
PrivaPub admin create-root <login> [--admin] the password is read from standard input
PrivaPub admin smoke <persona> prints "<login> <password>" for the deploy's signed-in check
PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end
PrivaPub admin delete-root <root> deletes a login and every persona it still has
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
what nothing holds and gives today's pictures their rows
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
@@ -51,8 +48,10 @@ namespace PrivaPub.Infrastructure.Cli
return await Promote(verb == "promote", userName, output);
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
case ["smoke", var persona]:
return await Smoke(services, persona, output);
case ["move-persona", var persona, var root]:
return await MovePersona(services, persona, root, output);
case ["delete-root", var root]:
return await DeleteRoot(services, root, output);
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
return await AuditMedia(services, flags.Contains("--fix"), output);
case ["backup", "verify", var id]:
@@ -230,58 +229,64 @@ namespace PrivaPub.Infrastructure.Cli
return Task.FromResult(0);
}
// The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run
// sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person.
static async Task<int> Smoke(IServiceProvider services, string personaName, TextWriter output)
// A persona given to another login (owner decision 2026-10-17, made for @thepra): its link moves first, so the old
// login can no longer exchange a token for it, then its Mastodon tokens and authorizations end. Its actor, posts and
// followers stay as they are, and nobody outside is told: nothing they hold changes.
static async Task<int> MovePersona(IServiceProvider services, string personaName, string login, TextWriter output)
{
personaName = personaName.ToLowerInvariant();
var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24));
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
if (root == default)
{
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password });
if (!created.IsValid)
{
Console.Error.WriteLine(created.ErrorMessage);
return 1;
}
root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
}
else if (root.DeletedAt.HasValue || root.IsBanned)
{
Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned");
return 1;
}
else
await DB.Default.Update<RootUser>().MatchID(root.ID)
.Modify(u => u.HashedPassword, services.GetRequiredService<IPasswordHasher>().Hash(password))
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
var persona = await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
personaName = personaName.TrimStart('@').ToLowerInvariant();
login = login.ToLowerInvariant();
var persona = await DB.Default.Find<Avatar>().Match(a => a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
if (persona == default)
{
var inserted = await services.GetRequiredService<IPrivateAvatarUsersService>().InsertAvatar(new InsertAvatarForm
{
RootId = root.ID,
UserName = personaName,
Name = personaName,
Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona."
});
if (!inserted.IsValid)
{
Console.Error.WriteLine(inserted.ErrorMessage);
return 1;
}
persona = await DB.Default.Find<Avatar>().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync();
Console.Error.WriteLine($"no persona '@{personaName}'");
return 1;
}
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login).ExecuteFirstAsync();
if (root == default || root.DeletedAt.HasValue || root.IsBanned)
{
Console.Error.WriteLine($"no root user '{login}' that can take it");
return 1;
}
var link = await DB.Default.Find<RootToAvatar>().Match(ra => ra.AvatarId == persona.ID).ExecuteFirstAsync();
if (link == default)
{
Console.Error.WriteLine($"'@{personaName}' belongs to no login");
return 1;
}
if (link.RootId == root.ID)
{
output.WriteLine($"@{personaName}: already {login}'s");
return 0;
}
await DB.Default.Update<Avatar>().MatchID(persona.ID)
.Modify(a => a.Settings.IsDiscoverable, false)
.Modify(a => a.Settings.IsIndexable, false)
.ExecuteAsync();
output.WriteLine($"{SmokeLogin} {password}");
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
output.WriteLine($"@{personaName}: moved to {login}");
return 0;
}
// A login deleted from the box as the client deletes its own (RootRemoval): every persona it still has goes with it,
// announced deleted, and its sessions end.
static async Task<int> DeleteRoot(IServiceProvider services, string login, TextWriter output)
{
login = login.ToLowerInvariant();
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
if (root == default)
{
Console.Error.WriteLine($"no root user '{login}'");
return 1;
}
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
var personas = (await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync())
.Select(a => "@" + a.UserName).ToList();
if (!await services.GetRequiredService<IRootRemoval>().Remove(root.ID, default))
{
Console.Error.WriteLine($"'{login}' could not be deleted");
return 1;
}
output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}");
return 0;
}
}