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.

Tighten account email management validation

alice 8454b8e4 5cc091df

+54 -5
+4 -3
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 `6f181ab`. 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. 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=2880` 16 + - latest full green result in the realigned Meridian worktree: `Files=48, Tests=2898` 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` ··· 49 49 - Deactivated accounts should still be able to establish and refresh sessions, but those responses must stay marked `active=false` with `status=deactivated`. 50 50 - Local `app.bsky.*` emulation must be conservative: only synthesize owner-local feed/thread data when the PDS can answer authoritatively, and proxy upstream instead of inventing partial global state. 51 51 - Account email handling needs consistent normalization on write, lookup, session creation, and confirmation checks; treating email case inconsistently leaves both tests and user-facing auth behavior brittle. 52 + - `com.atproto.server.requestEmailConfirmation`, `requestEmailUpdate`, and `requestAccountDelete` should reject accounts with no stored email using the official `400 InvalidRequest` / `account does not have an email address` shape, and `updateEmail` should reject unsupported syntax with the official `This email address is not supported, please use a different email.` message. 52 53 - `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. 53 54 - `com.atproto.identity.resolveHandle` should reject malformed handles with `400 InvalidRequest`, not quietly treat them as misses or return a local `InvalidHandle` variant. 54 55 - `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`. ··· 98 99 | `t/crawlers.t` | audited local regression | outbound crawl notification semantics | 99 100 | `t/crypto-interop.t` | direct reference differential | pinned upstream crypto fixture coverage | 100 101 | `t/delete-account.t` | audited local regression | reference-style account deletion flow using DID, password, and action token without a live bearer session | 101 - | `t/email-confirmation.t` | audited local regression | intentionally testing-friendly email flow | 102 + | `t/email-confirmation.t` | audited local regression | intentionally testing-friendly email flow plus strict missing-email and invalid-email validation semantics | 102 103 | `t/event-stream.t` | audited local regression | wire-format, malformed frame, and event decoding coverage | 103 104 | `t/extended-api.t` | audited local regression | broad XRPC behavior including invites and moderation-adjacent flows; still intentionally mixes conformance-ish happy paths with local-policy coverage | 104 105 | `t/external-surface.t` | audited local regression | external repo/account surface including missing-blob behavior; intentionally broad, with order-insensitive assertions for label presence rather than brittle label ordering |
+18 -2
lib/ATProto/PDS/API/Server.pm
··· 473 473 }, 474 474 ); 475 475 _assert_full_non_oauth_access($claims); 476 - return {} unless $account->{email}; 476 + xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 477 + unless defined($account->{email}) && length($account->{email}); 477 478 issue_account_action_token( 478 479 $c, 479 480 $account, ··· 527 528 }, 528 529 ); 529 530 _assert_full_non_oauth_access($claims); 531 + xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 532 + unless defined($account->{email}) && length($account->{email}); 530 533 my $token_required = defined $account->{email_confirmed_at} ? 1 : 0; 531 534 if ($token_required) { 532 535 issue_account_action_token( ··· 550 553 disallow_oauth => 1, 551 554 ); 552 555 my $body = $c->req->json || {}; 556 + my $email = _supported_email($body->{email}); 557 + xrpc_error(400, 'InvalidRequest', 'This email address is not supported, please use a different email.') 558 + unless defined $email; 553 559 if (defined $account->{email_confirmed_at}) { 554 560 xrpc_error(400, 'TokenRequired', 'A confirmation token is required to update email') 555 561 unless defined($body->{token}) && length($body->{token}); ··· 561 567 unless ($token->{did} // q()) eq $account->{did}; 562 568 $c->store->consume_action_token($token->{token}); 563 569 } 564 - update_account_email($c, $account->{did}, $body->{email}); 570 + update_account_email($c, $account->{did}, $email); 565 571 return {}; 566 572 }); 567 573 ··· 572 578 required_scope => 'full', 573 579 disallow_oauth => 1, 574 580 ); 581 + xrpc_error(400, 'InvalidRequest', 'account does not have an email address') 582 + unless defined($account->{email}) && length($account->{email}); 575 583 issue_account_action_token( 576 584 $c, 577 585 $account, ··· 1149 1157 sub _normalize_email ($email) { 1150 1158 return undef unless defined $email; 1151 1159 return lc $email; 1160 + } 1161 + 1162 + sub _supported_email ($email) { 1163 + my $normalized = _normalize_email($email); 1164 + return undef unless defined($normalized) && length($normalized); 1165 + return undef if $normalized =~ /\s/; 1166 + 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/; 1167 + return $normalized; 1152 1168 } 1153 1169 1154 1170 sub _require_action_token ($c, %args) {
+32
t/email-confirmation.t
··· 124 124 'stale confirmation tokens cannot confirm a changed email address', 125 125 ); 126 126 127 + $t->post_ok('/xrpc/com.atproto.server.updateEmail' => { 128 + Authorization => "Bearer $alice->{accessJwt}", 129 + } => json => { 130 + email => 'not-an-email', 131 + })->status_is(400) 132 + ->json_is('/error' => 'InvalidRequest') 133 + ->json_is('/message' => 'This email address is not supported, please use a different email.'); 134 + 127 135 $t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 128 136 handle => 'bob.example.test', 129 137 email => 'bob@example.test', ··· 190 198 token => $carol_token->{token}, 191 199 })->status_is(200) 192 200 ->json_is({}); 201 + 202 + $bypass_t->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 203 + handle => 'noemail.example.test', 204 + password => 'hunter22', 205 + })->status_is(200); 206 + my $noemail = $bypass_t->tx->res->json; 207 + 208 + $bypass_t->post_ok('/xrpc/com.atproto.server.requestEmailConfirmation' => { 209 + Authorization => "Bearer $noemail->{accessJwt}", 210 + } => json => {})->status_is(400) 211 + ->json_is('/error' => 'InvalidRequest') 212 + ->json_is('/message' => 'account does not have an email address'); 213 + 214 + $bypass_t->post_ok('/xrpc/com.atproto.server.requestEmailUpdate' => { 215 + Authorization => "Bearer $noemail->{accessJwt}", 216 + } => json => {})->status_is(400) 217 + ->json_is('/error' => 'InvalidRequest') 218 + ->json_is('/message' => 'account does not have an email address'); 219 + 220 + $bypass_t->post_ok('/xrpc/com.atproto.server.requestAccountDelete' => { 221 + Authorization => "Bearer $noemail->{accessJwt}", 222 + } => json => {})->status_is(400) 223 + ->json_is('/error' => 'InvalidRequest') 224 + ->json_is('/message' => 'account does not have an email address'); 193 225 194 226 done_testing;