Compare commits

..
1 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 c42229f78c 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>
2026-10-17 00:12:56 +02:00
6 changed files with 113 additions and 94 deletions

No files matched your search

+2 -16
View File
@@ -128,22 +128,8 @@ jobs:
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
echo "::notice::serving $served"
# @thepra is the deploy's own persona (owner decision, 2026-10-04): the server's CLI makes or keeps it, undiscoverable,
# and gives its root a new password on every deploy, so no secret is stored and nobody has to create anything.
- name: Sign in as @thepra and check the signed-in API
run: |
creds=$(cd "$WEB_ROOT" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke thepra | grep -E '^deploy-smoke [^ ]+$' | tail -1)
[ -n "$creds" ] || { echo "::error::the smoke persona could not be prepared"; exit 1; }
read -r login password <<< "$creds"
echo "::add-mask::$password"
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
echo "::add-mask::$token"
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
discoverable=$(curl -fsS -H "Authorization: Bearer $token" "$PUBLIC_URL/api/v1/accounts/verify_credentials" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
echo "::notice::signed in as @thepra"
# The deploy no longer signs in (owner decision 2026-10-17): it kept a login of its own, deploy-smoke, whose @thepra
# is the administrator's now. Its checks are the anonymous ones above.
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
- name: Geolocation databases are current
+4 -4
View File
@@ -169,7 +169,8 @@ made (signing up as "admin" grants nothing):
cd /var/www/privapub.thepra.dev
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke <persona> # the deploy's: prints "deploy-smoke <new password>"
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root <root> # as the client deletes a login: its personas go too
```
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
@@ -945,9 +946,8 @@ the owner's GoToSocial account.**
discovery, instance, public timeline). Then it checks that what production should be running is running:
- NodeInfo and the instance API agree on registrations (closed, invitations only);
- `/stargazing` says the crawler is on;
- **@thepra signs in**: `PrivaPub admin smoke thepra` gives the root `deploy-smoke` a new password, `tools/smoke/oauth.sh`
runs the real OAuth flow (the pasture's `privapub_token` uses the same script), the signed-in API is checked, the
persona must be undiscoverable, and the token is revoked;
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
administrator and was deleted, and the signed-in check went with it;
- the DB-IP Lite databases, which the server fetches itself (`GeoUpdater`), exist and are at most 40 days old.
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
`ASPNETCORE_ENVIRONMENT=Production`.
@@ -121,27 +121,55 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Contains(Policies.IsAdmin, stored.Policies);
}
async Task<List<string>> PersonaIds(Root root)
{
using var client = _host.As(root.Jwt);
return (await (await client.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems())
.Select(a => a!["id"]!.GetValue<string>()).ToList();
}
async Task<HttpStatusCode> VerifyCredentials(string mastodonToken)
{
using var client = _host.As(mastodonToken);
return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode;
}
[Fact]
public async Task Smoke_keeps_one_undiscoverable_persona_and_a_new_password_each_run()
public async Task Move_persona_gives_a_persona_to_another_login_and_ends_its_tokens()
{
using var scope = _host.Services.CreateScope();
var persona = $"smoke{Guid.NewGuid():N}"[..20];
var first = new StringWriter();
var second = new StringWriter();
var giver = await _host.SignUp("giver");
var taker = await _host.SignUp("taker");
var persona = await _host.Persona(giver, "moved");
var token = await _host.MastodonToken(persona);
Assert.Equal(HttpStatusCode.OK, await VerifyCredentials(token));
Assert.Equal(0, await AdminCommands.Run(new[] { "smoke", persona }, scope.ServiceProvider, output: first));
Assert.Equal(0, await AdminCommands.Run(new[] { "smoke", persona }, scope.ServiceProvider, output: second));
Assert.Equal(0, await AdminCommands.Run(new[] { "move-persona", "@" + persona.UserName, taker.UserName.ToUpperInvariant() }, scope.ServiceProvider, output: new StringWriter()));
Assert.Equal(0, await AdminCommands.Run(new[] { "move-persona", persona.UserName, taker.UserName }, scope.ServiceProvider, output: new StringWriter()));
var (login, oldPassword) = (first.ToString().Trim().Split(' ')[0], first.ToString().Trim().Split(' ')[1]);
var newPassword = second.ToString().Trim().Split(' ')[1];
Assert.Equal(AdminCommands.SmokeLogin, login);
Assert.NotEqual(oldPassword, newPassword);
Assert.Equal(HttpStatusCode.OK, await LogIn(login, newPassword));
Assert.NotEqual(HttpStatusCode.OK, await LogIn(login, oldPassword));
var avatars = await DB.Default.Find<Avatar>().Match(a => a.UserName == persona).ExecuteAsync(TestContext.Current.CancellationToken);
var avatar = Assert.Single(avatars);
Assert.False(avatar.Settings.IsDiscoverable);
Assert.False(avatar.Settings.IsIndexable);
Assert.Equal(HttpStatusCode.Unauthorized, await VerifyCredentials(token));
Assert.DoesNotContain(persona.Id, await PersonaIds(giver));
Assert.Contains(persona.Id, await PersonaIds(taker));
Assert.Equal(1, await AdminCommands.Run(new[] { "move-persona", $"nobody{Guid.NewGuid():N}"[..20], taker.UserName }, scope.ServiceProvider));
Assert.Equal(1, await AdminCommands.Run(new[] { "move-persona", persona.UserName, $"nobody{Guid.NewGuid():N}" }, scope.ServiceProvider));
}
[Fact]
public async Task Delete_root_deletes_a_login_with_the_personas_it_still_has()
{
var cancellation = TestContext.Current.CancellationToken;
using var scope = _host.Services.CreateScope();
var root = await _host.SignUp("leaving");
var persona = await _host.Persona(root, "leaving");
var output = new StringWriter();
Assert.Equal(0, await AdminCommands.Run(new[] { "delete-root", root.UserName }, scope.ServiceProvider, output: output));
Assert.Equal(1, await AdminCommands.Run(new[] { "delete-root", root.UserName }, scope.ServiceProvider, output: new StringWriter()));
Assert.Contains("@" + persona.UserName, output.ToString());
Assert.NotEqual(HttpStatusCode.OK, await LogIn(root.UserName, root.Password));
Assert.NotNull((await DB.Default.Find<RootUser>().OneAsync(root.Id, cancellation)).DeletedAt);
Assert.NotNull((await DB.Default.Find<Avatar>().OneAsync(persona.Id, cancellation)).DeletionAt);
}
}
}
+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;
}
}
+1 -1
View File
@@ -248,7 +248,7 @@ and circles (see Owner decisions).
| Geolocation | **The server fetches DB-IP Lite itself** (`GeoUpdater`): it checks daily, installs a new month's databases once they are published, refuses a file that does not open as the right kind of database, and keeps the old one when anything fails. No timer and no root step. The deploy fails if the databases are missing or more than 40 days old. |
| Crawler | **On in production**, seeded with a handful of large servers of different kinds (`appsettings.Production.json`); the deploy fails if `/stargazing` does not say it is on. |
| Sign-up | **Closed: invitations only.** A group invitation creates an account; open sign-up answers 403. NodeInfo, `/api/v1/instance` and `/api/v2/instance` read the same switch (`Registrations:Mode`), and the deploy fails if they disagree. The first login on a server is made from the client's setup page while no administrator exists (2026-10-16: first come, first served, no setup code); it becomes the administrator with its first persona, and setup closes for good (`ServerSetup`, kept across restores). `PrivaPub admin create-root` remains the way in from the box. |
| Signed-in smoke check | **`@thepra`, undiscoverable, made and kept by the deploy**: `PrivaPub admin smoke thepra` creates or keeps the root `deploy-smoke` and the persona and gives the root a new password on every deploy; the deploy signs in through the real OAuth flow, checks the signed-in API and revokes its token. No secret is stored. |
| Signed-in smoke check | **Dropped (2026-10-17).** It was `@thepra`, undiscoverable, made and kept by the deploy under the root `deploy-smoke`. The owner took `@thepra` for the administrator's login (`PrivaPub admin move-persona thepra <root>`), `deploy-smoke` was deleted (`admin delete-root`), and the deploy keeps only its anonymous checks. |
| Signed fetches (SecureMode) | **On in production** once the pasture passes with it on (Phase 2 of the 2026-10-04 plan). |
| Circles on Mastodon and GoToSocial | **Each member's copy names that member** in `cc`; a member's refetch names the member, an instance actor's refetch the members on its server. Nothing new is revealed to anyone outside the circle. |
| What circles and located posts reveal | **Unchanged**: circles still answer WebFinger, and circle and located posts still count in a persona's post count, "a good balance for the fediverse to work". |
+1 -1
View File
@@ -43,7 +43,7 @@ privapub_root() {
}
# privapub_token <persona>: creates the persona under the pasture root if needed and returns a Mastodon token for it,
# through the same OAuth flow the deploy's smoke check uses (tools/smoke/oauth.sh).
# through the real OAuth flow (tools/smoke/oauth.sh).
privapub_token() {
local persona=$1 jwt
jwt=$(privapub_root)