···291291292292If you are running without outbound email during smoke/dev work, the safer testing knobs are:
293293294294-- `testing_auto_confirm_email`: mark new-account emails as confirmed immediately.
294294+- `testing_auto_confirm_email`: explicitly opt into marking new-account emails as confirmed immediately.
295295- `testing_allow_unauthenticated_email_confirm`: allow `com.atproto.server.confirmEmail` without a bearer token for local testing only.
296296297297Both are intended for testing environments. Leave them off in normal deployments.
+2
docs/TEST_AUDIT.md
···4949- Deactivated accounts should still be able to establish and refresh sessions, but those responses must stay marked `active=false` with `status=deactivated`.
5050- 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.
5151- 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.
5252+- Email confirmation, email update, and account-delete action-token flows now match the official runtime on the client-visible surface, including default non-auto-confirm account creation, case-insensitive confirmation matching, `confirmation token required`, and `Token is expired` error text.
5253- `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.
5354- perlsky intentionally still allows test-friendly no-email local accounts, but once an email is supplied `com.atproto.server.createAccount` now follows the same unsupported-syntax rejection shape as `updateEmail` and the official runtime.
5455- `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.
5656+- The executable differential harness now covers the full email/account-delete lifecycle against the official runtime. No-email local account creation remains a documented local extension and is intentionally excluded from that executable comparison.
5557- 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.
5658- The executable differential harness now proves that handle-conflict shape directly for both user and admin handle-update flows, not just local regression tests.
5759- `com.atproto.server.createSession` invalid-credential failures now use the reference runtime’s `401 AuthenticationRequired` shape instead of the older local `AuthRequired` variant.
+4-4
lib/ATProto/PDS/API/Server.pm
···531531 my $account = $c->store->get_account_by_did($token->{did});
532532 xrpc_error(404, 'AccountNotFound', 'Account was not found') unless $account;
533533 my $email = _normalize_email($body->{email}) // q();
534534- xrpc_error(400, 'InvalidEmail', 'Token was not issued for that email')
534534+ xrpc_error(400, 'InvalidEmail', 'invalid email')
535535 unless length($email)
536536 && ($token->{email} // q()) eq $email
537537 && ($account->{email} // q()) eq $email;
···582582 xrpc_error(400, 'InvalidRequest', 'This email address is not supported, please use a different email.')
583583 unless defined $email;
584584 if (defined $account->{email_confirmed_at}) {
585585- xrpc_error(400, 'TokenRequired', 'A confirmation token is required to update email')
585585+ xrpc_error(400, 'TokenRequired', 'confirmation token required')
586586 unless defined($body->{token}) && length($body->{token});
587587 my $token = _require_action_token($c,
588588 token => $body->{token},
···1177117711781178sub _initial_email_confirmed_at ($c, $email) {
11791179 return undef unless defined $email && length $email;
11801180- return undef unless $c->config_value('testing_auto_confirm_email', 1);
11801180+ return undef unless $c->config_value('testing_auto_confirm_email', 0);
11811181 return time;
11821182}
11831183···11941194 xrpc_error(400, 'InvalidToken', 'Token purpose did not match')
11951195 unless ($token->{purpose} // q()) eq ($args{purpose} // q());
11961196 xrpc_error(400, 'InvalidToken', 'Token has already been used') if defined $token->{consumed_at};
11971197- xrpc_error(400, 'ExpiredToken', 'Token has expired')
11971197+ xrpc_error(400, 'ExpiredToken', 'Token is expired')
11981198 if defined($token->{expires_at}) && $token->{expires_at} < time;
11991199 return $token;
12001200}