perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Align admin account semantics with reference

alice 7ba68672 d9aac332

+179 -32
+7 -2
docs/TEST_AUDIT.md
··· 1 1 # Test Audit Status 2 2 3 - As of 2026-03-12, the focused test-correctness and reference-audit pass is complete on rewritten history through the current post-`6f181ab` conformance sweep. 3 + As of 2026-03-12, the focused test-correctness and reference-audit pass is complete on rewritten history through the current overnight conformance sweep. 4 4 5 5 That does not mean every test has been manually revalidated against every other PDS implementation line by line. It means: 6 6 ··· 13 13 The current baseline for saying "the audited suite is green" is: 14 14 15 15 - `prove -lr t` 16 - - latest full green result in the realigned Meridian worktree: `Files=48, Tests=2943` 16 + - latest full green result in the realigned Meridian worktree: `Files=48, Tests=2950` 17 17 - `prove -lv t/server-auth.t` 18 18 - `perl -c script/differential-validate` 19 19 - `PERLSKY_RUN_REFERENCE_DIFF=1 prove -lv t/reference-differential.t` ··· 54 54 - `com.atproto.server.createAccount` must not turn duplicate-email requests into a `500`; it now follows the official client-visible `400 InvalidRequest` / `Email already taken: ...` shape instead. 55 55 - Local handle-conflict flows now use the reference runtime’s client-visible `400 InvalidRequest` / `Handle already taken: ...` shape on `createAccount`, `com.atproto.identity.updateHandle`, and `com.atproto.admin.updateAccountHandle`, instead of the older local `HandleNotAvailable` variant. 56 56 - The executable differential harness now proves that handle-conflict shape directly for both user and admin handle-update flows, not just local regression tests. 57 + - `com.atproto.server.createSession` invalid-credential failures now use the reference runtime’s `401 AuthenticationRequired` shape instead of the older local `AuthRequired` variant. 58 + - `com.atproto.admin.sendEmail` now follows the reference runtime’s `400 InvalidRequest` / `Recipient not found` shape for a missing recipient instead of returning a local `404 AccountNotFound`. 59 + - `com.atproto.admin.updateAccountPassword` follows the reference runtime’s looser admin policy: it rejects overlong passwords with `400 InvalidRequest` / `Invalid password length.`, but does not impose the normal user-facing minimum-length gate. 60 + - `com.atproto.admin.disableAccountInvites` / `enableAccountInvites` now ignore the local `note` field so the visible account state matches the official runtime instead of carrying an extra stored `inviteNote`. 57 61 - `app.bsky.actor.putPreferences` and `app.bsky.notification.putPreferencesV2` now have explicit shape validation plus focused regression coverage, turning an earlier hardening concern into a pinned contract. 58 62 - `com.atproto.identity.resolveHandle` should reject malformed handles with `400 InvalidRequest`, not quietly treat them as misses or return a local `InvalidHandle` variant. 59 63 - `com.atproto.identity.resolveHandle` should treat well-formed but unresolved handles as `400 InvalidRequest` with `Unable to resolve handle`, matching the official runtime instead of returning a local `404 HandleNotFound`. ··· 78 82 79 83 - Email confirmation remains testing-friendly only behind the explicit `testing_allow_unauthenticated_email_confirm` / `testing_auto_confirm_email` toggles because email sending is not configured in the current environment. 80 84 - Admin auth still accepts a local bearer-token shortcut, while the official reference PDS expects Basic auth with `admin` credentials. 85 + - `com.atproto.admin.searchAccounts` remains locally implemented and regression-tested, but the current official runtime does not wire that endpoint at all, so it stays documented as a local extension instead of executable-reference-differenced. 81 86 - Self-service invite creation exists only behind `self_service_invite_codes`; default behavior is admin-only invite minting. 82 87 - Label RPC parity is covered locally, but there is no like-for-like official local-labeler surface to diff against in the same way as core PDS endpoints. 83 88
+5 -4
lib/ATProto/PDS/API/Admin.pm
··· 17 17 use ATProto::PDS::Moderation qw(current_record_subject current_subject_status parse_at_uri); 18 18 19 19 our @EXPORT_OK = qw(register_admin_handlers); 20 + my $NEW_PASSWORD_MAX_LENGTH = 256; 20 21 21 22 sub register_admin_handlers ($registry, $app) { 22 23 $registry->register('com.atproto.admin.getAccountInfo', sub ($c, $endpoint) { ··· 110 111 require_admin($c); 111 112 my $body = $c->req->json || {}; 112 113 my $account = $c->store->get_account_by_did($body->{recipientDid} // q()); 113 - xrpc_error(404, 'AccountNotFound', 'Recipient was not found') unless $account; 114 + xrpc_error(400, 'InvalidRequest', 'Recipient not found') unless $account; 114 115 xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 115 116 unless defined($account->{email}) && length($account->{email}); 116 117 my $subject = defined($body->{subject}) && length($body->{subject}) ··· 159 160 $registry->register('com.atproto.admin.updateAccountPassword', sub ($c, $endpoint) { 160 161 require_admin($c); 161 162 my $body = $c->req->json || {}; 162 - xrpc_error(400, 'InvalidPassword', 'Passwords must be at least 8 characters long') 163 - if length($body->{password} // q()) < 8; 163 + xrpc_error(400, 'InvalidRequest', 'Invalid password length.') 164 + if length($body->{password} // q()) > $NEW_PASSWORD_MAX_LENGTH; 164 165 my $account = $c->store->get_account_by_did($body->{did} // q()); 165 166 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account; 166 167 my $password_record = hash_password($body->{password}); ··· 383 384 $c->store->update_account( 384 385 $account->{did}, 385 386 invites_disabled => $disabled ? 1 : 0, 386 - invite_note => $note, 387 + invite_note => undef, 387 388 ); 388 389 return {}; 389 390 }
+10
lib/ATProto/PDS/API/Helpers.pm
··· 24 24 issue_account_action_token 25 25 invite_code_view 26 26 require_admin 27 + supported_email 27 28 subject_key 28 29 update_account_email 29 30 verify_account_password ··· 98 99 ); 99 100 } 100 101 return $token; 102 + } 103 + 104 + sub supported_email ($email) { 105 + return undef unless defined $email; 106 + my $normalized = lc $email; 107 + return undef unless length $normalized; 108 + return undef if $normalized =~ /\s/; 109 + return undef unless $normalized =~ /\A[^\s\@]+\@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+\z/; 110 + return $normalized; 101 111 } 102 112 103 113 sub update_account_email ($c, $did, $email) {
+7 -15
lib/ATProto/PDS/API/Server.pm
··· 11 11 use Mojo::URL; 12 12 use Mojo::UserAgent; 13 13 14 - use ATProto::PDS::API::Helpers qw(find_account invite_code_view issue_account_action_token require_admin update_account_email verify_account_password verify_login_password); 14 + use ATProto::PDS::API::Helpers qw(find_account invite_code_view issue_account_action_token require_admin supported_email update_account_email verify_account_password verify_login_password); 15 15 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 16 16 use ATProto::PDS::Auth::OAuth qw( 17 17 oauth_scope_allows ··· 79 79 if length($password) > $NEW_PASSWORD_MAX_LENGTH; 80 80 my $email = undef; 81 81 if (defined($body->{email}) && length($body->{email})) { 82 - $email = _supported_email($body->{email}); 82 + $email = supported_email($body->{email}); 83 83 xrpc_error(400, 'InvalidRequest', 'This email address is not supported, please use a different email.') 84 84 unless defined $email; 85 85 xrpc_error(400, 'InvalidRequest', "Email already taken: $body->{email}") ··· 229 229 xrpc_error(401, 'AuthenticationRequired', 'Password too long. Consider resetting your password.') 230 230 if length($body->{password} // q()) > $OLD_PASSWORD_MAX_LENGTH; 231 231 my $account = find_account($c, $body->{identifier} // q()); 232 - xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') unless $account; 233 - xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') 232 + xrpc_error(401, 'AuthenticationRequired', 'Invalid identifier or password') unless $account; 233 + xrpc_error(401, 'AuthenticationRequired', 'Invalid identifier or password') 234 234 if defined $account->{deleted_at}; 235 235 my $authn = verify_login_password($c, $account, $body->{password} // q()); 236 - xrpc_error(401, 'AuthRequired', 'Invalid identifier or password') unless $authn; 236 + xrpc_error(401, 'AuthenticationRequired', 'Invalid identifier or password') unless $authn; 237 237 if (($authn->{kind} // q()) eq 'app_password' && is_repo_takedown($c, $account->{did})) { 238 - xrpc_error(401, 'AuthRequired', 'Invalid identifier or password'); 238 + xrpc_error(401, 'AuthenticationRequired', 'Invalid identifier or password'); 239 239 } 240 240 assert_login_allowed( 241 241 $c, ··· 578 578 disallow_oauth => 1, 579 579 ); 580 580 my $body = $c->req->json || {}; 581 - my $email = _supported_email($body->{email}); 581 + my $email = supported_email($body->{email}); 582 582 xrpc_error(400, 'InvalidRequest', 'This email address is not supported, please use a different email.') 583 583 unless defined $email; 584 584 if (defined $account->{email_confirmed_at}) { ··· 1184 1184 sub _normalize_email ($email) { 1185 1185 return undef unless defined $email; 1186 1186 return lc $email; 1187 - } 1188 - 1189 - sub _supported_email ($email) { 1190 - my $normalized = _normalize_email($email); 1191 - return undef unless defined($normalized) && length($normalized); 1192 - return undef if $normalized =~ /\s/; 1193 - return undef unless $normalized =~ /\A[^\s\@]+\@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+\z/; 1194 - return $normalized; 1195 1187 } 1196 1188 1197 1189 sub _require_action_token ($c, %args) {
+125 -1
script/differential-validate
··· 572 572 my $json = $res->json || {}; 573 573 $server{$name}{secondary_did} = $json->{did}; 574 574 $server{$name}{secondary_handle} = $json->{handle}; 575 + $server{$name}{secondary_email} = $name eq 'reference' ? 'bob-ref@test.com' : 'bob-perl@test.com'; 575 576 } 576 577 577 578 note('Comparing account password boundary semantics'); ··· 643 644 }, 644 645 admin_auth_header($server{$name}{admin_password}), 645 646 ); 646 - 647 647 $server{$name}{handle_conflicts} = { 648 648 identity_update_dup => normalize_xrpc_error($duplicate_identity_handle), 649 649 admin_update_dup => normalize_xrpc_error($duplicate_admin_handle), ··· 2212 2212 same_hash($server{reference}{service_auth_takedown}, $server{perlsky}{service_auth_takedown}), 2213 2213 'getServiceAuth agrees on post-takedown behavior', 2214 2214 ); 2215 + 2216 + note('Comparing admin account-management semantics'); 2217 + for my $name (sort keys %server) { 2218 + my $info = get_form( 2219 + $server{$name}{origin}, 2220 + 'com.atproto.admin.getAccountInfos', 2221 + { 2222 + dids => [ $server{$name}{did}, 'did:web:missing.test' ], 2223 + }, 2224 + admin_auth_header($server{$name}{admin_password}), 2225 + ); 2226 + my $send_missing = post_json( 2227 + $server{$name}{origin}, 2228 + 'com.atproto.admin.sendEmail', 2229 + { 2230 + recipientDid => 'did:web:missing.test', 2231 + content => 'hello', 2232 + }, 2233 + admin_auth_header($server{$name}{admin_password}), 2234 + ); 2235 + my $disable_admin = post_json( 2236 + $server{$name}{origin}, 2237 + 'com.atproto.admin.disableInviteCodes', 2238 + { accounts => ['admin'] }, 2239 + admin_auth_header($server{$name}{admin_password}), 2240 + ); 2241 + my $disable_invites = post_json( 2242 + $server{$name}{origin}, 2243 + 'com.atproto.admin.disableAccountInvites', 2244 + { 2245 + account => $server{$name}{did}, 2246 + note => 'diff disable', 2247 + }, 2248 + admin_auth_header($server{$name}{admin_password}), 2249 + ); 2250 + my $disabled_info = get_form( 2251 + $server{$name}{origin}, 2252 + 'com.atproto.admin.getAccountInfo', 2253 + { did => $server{$name}{did} }, 2254 + admin_auth_header($server{$name}{admin_password}), 2255 + ); 2256 + my $enable_invites = post_json( 2257 + $server{$name}{origin}, 2258 + 'com.atproto.admin.enableAccountInvites', 2259 + { 2260 + account => $server{$name}{did}, 2261 + note => 'diff enable', 2262 + }, 2263 + admin_auth_header($server{$name}{admin_password}), 2264 + ); 2265 + my $enabled_info = get_form( 2266 + $server{$name}{origin}, 2267 + 'com.atproto.admin.getAccountInfo', 2268 + { did => $server{$name}{did} }, 2269 + admin_auth_header($server{$name}{admin_password}), 2270 + ); 2271 + my $short_password = post_json( 2272 + $server{$name}{origin}, 2273 + 'com.atproto.admin.updateAccountPassword', 2274 + { 2275 + did => $server{$name}{secondary_did}, 2276 + password => 'short', 2277 + }, 2278 + admin_auth_header($server{$name}{admin_password}), 2279 + ); 2280 + my $reset_password = post_json( 2281 + $server{$name}{origin}, 2282 + 'com.atproto.admin.updateAccountPassword', 2283 + { 2284 + did => $server{$name}{secondary_did}, 2285 + password => 'newhunter22', 2286 + }, 2287 + admin_auth_header($server{$name}{admin_password}), 2288 + ); 2289 + my $old_secondary_login = post_json($server{$name}{origin}, 'com.atproto.server.createSession', { 2290 + identifier => $server{$name}{secondary_handle}, 2291 + password => 'hunter22', 2292 + }); 2293 + my $new_secondary_login = post_json($server{$name}{origin}, 'com.atproto.server.createSession', { 2294 + identifier => $server{$name}{secondary_handle}, 2295 + password => 'newhunter22', 2296 + }); 2297 + 2298 + my $info_json = $info->json || {}; 2299 + my @infos = @{ $info_json->{infos} || [] }; 2300 + my $disabled_json = $disabled_info->json || {}; 2301 + my $enabled_json = $enabled_info->json || {}; 2302 + 2303 + $server{$name}{admin_account_management} = { 2304 + get_infos => { 2305 + status => $info->code // 0, 2306 + returned_count => scalar @infos, 2307 + first_is_primary => (@infos && (($infos[0]{did} // q()) eq $server{$name}{did})) ? 1 : 0, 2308 + }, 2309 + send_missing => normalize_xrpc_error($send_missing), 2310 + disable_admin_codes => normalize_xrpc_error($disable_admin), 2311 + disable_account_invites => { 2312 + status => $disable_invites->code // 0, 2313 + disabled_status => $disabled_info->code // 0, 2314 + invites_disabled => $disabled_json->{invitesDisabled} ? 1 : 0, 2315 + invite_note => $disabled_json->{inviteNote}, 2316 + }, 2317 + enable_account_invites => { 2318 + status => $enable_invites->code // 0, 2319 + enabled_status => $enabled_info->code // 0, 2320 + invites_disabled => $enabled_json->{invitesDisabled} ? 1 : 0, 2321 + invite_note => $enabled_json->{inviteNote}, 2322 + }, 2323 + update_password_short => normalize_xrpc_error($short_password), 2324 + update_password_reset => { 2325 + status => $reset_password->code // 0, 2326 + old_login => normalize_xrpc_error($old_secondary_login), 2327 + new_login => normalize_xrpc_error($new_secondary_login), 2328 + }, 2329 + }; 2330 + } 2331 + 2332 + if (!same_hash($server{reference}{admin_account_management}, $server{perlsky}{admin_account_management})) { 2333 + note('reference admin account management: ' . encode_json($server{reference}{admin_account_management})); 2334 + note('perlsky admin account management: ' . encode_json($server{perlsky}{admin_account_management})); 2335 + fail_check('admin account-management semantics match the official reference PDS'); 2336 + } else { 2337 + pass('admin account-management semantics match the official reference PDS'); 2338 + } 2215 2339 2216 2340 if ($failed) { 2217 2341 print "\nReference PDS log:\n";
+1 -1
t/app.t
··· 59 59 60 60 $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { identifier => 'alice', password => 'pw' }) 61 61 ->status_is(401) 62 - ->json_is('/error' => 'AuthRequired'); 62 + ->json_is('/error' => 'AuthenticationRequired'); 63 63 64 64 my $tmp = tempdir(CLEANUP => 1); 65 65 my $fresh = Test::Mojo->new(ATProto::PDS->new(
+1 -1
t/delete-account.t
··· 100 100 identifier => 'alice.example.test', 101 101 password => 'hunter22', 102 102 })->status_is(401) 103 - ->json_is('/error' => 'AuthRequired'); 103 + ->json_is('/error' => 'AuthenticationRequired'); 104 104 105 105 $t->post_ok('/xrpc/com.atproto.server.deleteAccount' => json => { 106 106 did => $did,
+2 -2
t/metrics.t
··· 99 99 identifier => 'alice.test', 100 100 password => 'wrong-password', 101 101 })->status_is(401) 102 - ->json_is('/error' => 'AuthRequired'); 102 + ->json_is('/error' => 'AuthenticationRequired'); 103 103 104 104 $t->get_ok('/xrpc/example.unsupported.method') 105 105 ->status_is(404) ··· 139 139 ); 140 140 like( 141 141 $metrics, 142 - qr/perlsky_xrpc_errors_total\{endpoint_type="procedure",error="AuthRequired",method="POST",nsid="com\.atproto\.server\.createSession",status="401"\} 1\b/, 142 + qr/perlsky_xrpc_errors_total\{endpoint_type="procedure",error="AuthenticationRequired",method="POST",nsid="com\.atproto\.server\.createSession",status="401"\} 1\b/, 143 143 'handled XRPC errors are exported with their error code', 144 144 ); 145 145 like(
+1 -1
t/password-reset.t
··· 82 82 identifier => 'alice.example.test', 83 83 password => 'hunter22', 84 84 })->status_is(401) 85 - ->json_is('/error' => 'AuthRequired'); 85 + ->json_is('/error' => 'AuthenticationRequired'); 86 86 87 87 $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 88 88 identifier => 'alice.example.test',
+20 -5
t/uncovered-endpoints.t
··· 274 274 Authorization => $admin_auth, 275 275 })->status_is(200) 276 276 ->json_is('/invitesDisabled' => JSON::PP::true) 277 - ->json_is('/inviteNote' => 'paused for audit'); 277 + ->json_hasnt('/inviteNote'); 278 278 279 279 $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 280 280 Authorization => "Bearer $access", ··· 358 358 recipientDid => 'did:web:example.test:users:missing', 359 359 subject => 'Hello', 360 360 content => 'Testing', 361 - })->status_is(404) 362 - ->json_is('/error' => 'AccountNotFound'); 361 + })->status_is(400) 362 + ->json_is('/error' => 'InvalidRequest') 363 + ->json_is('/message' => 'Recipient not found'); 364 + 365 + $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 366 + Authorization => $admin_auth, 367 + } => json => { 368 + did => $did, 369 + password => 'short', 370 + })->status_is(200) 371 + ->json_is({}); 372 + 373 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 374 + identifier => 'alice.external.test', 375 + password => 'short', 376 + })->status_is(200) 377 + ->json_has('/accessJwt'); 363 378 364 379 $t->post_ok('/xrpc/com.atproto.admin.updateAccountPassword' => { 365 380 Authorization => $admin_auth, ··· 430 445 identifier => 'alice.example.test', 431 446 password => $app_password, 432 447 })->status_is(401) 433 - ->json_is('/error' => 'AuthRequired'); 448 + ->json_is('/error' => 'AuthenticationRequired'); 434 449 435 450 $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 436 451 identifier => 'alice.example.test', 437 452 password => 'new-hunter22', 438 453 })->status_is(401) 439 - ->json_is('/error' => 'AuthRequired'); 454 + ->json_is('/error' => 'AuthenticationRequired'); 440 455 441 456 done_testing;