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 identity resolution semantics

alice 6f181ab0 12563f3e

+142 -10
+1 -1
docs/TEST_AUDIT.md
··· 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 52 - `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 - - `com.atproto.identity.resolveHandle` should reject malformed handles with `400 InvalidHandle`, not quietly treat them as misses. 53 + - `com.atproto.identity.resolveHandle` should reject malformed handles with `400 InvalidRequest`, not quietly treat them as misses or return a local `InvalidHandle` variant. 54 54 - `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`. 55 55 - Remote `did:web` DID docs, conservative `resolveIdentity` handle validation, and external handle adoption all need explicit coverage because small resolver-policy drifts turn into visible interop bugs quickly. 56 56 - Remote `did:plc` DID docs should resolve through the PLC directory defaults even when `plc_url` is not explicitly configured; gating that path on local config silently breaks federated identity lookups.
+22 -3
lib/ATProto/PDS/API/Builtins.pm
··· 36 36 37 37 my $account = $c->store->get_account_by_did(_canonical_did($did)); 38 38 if ($account) { 39 + die { 40 + status => 400, 41 + error => 'InvalidRequest', 42 + message => 'Unable to resolve DID', 43 + } unless _local_account_identity_resolvable($c, $account); 39 44 return { 40 45 didDoc => $account->{did_doc} || account_did_doc($c->app->settings, $account), 41 46 }; ··· 66 71 my $handle = normalize_handle($raw_handle, undef, { no_append => 1 }); 67 72 die { 68 73 status => 400, 69 - error => 'InvalidHandle', 70 - message => 'Handle is invalid', 74 + error => 'InvalidRequest', 75 + message => 'Unable to resolve handle', 71 76 } unless defined $handle && length $handle; 72 77 if (my $did = _resolve_handle_to_did($c, $handle)) { 73 78 return { did => $did }; ··· 85 90 my $service_did = lc(service_did($c->app->settings)); 86 91 my $service_handle = lc($c->config_value('service_handle_domain', 'localhost')); 87 92 if (my $account = $identifier =~ /^did:/ ? $c->store->get_account_by_did(_canonical_did($identifier)) : $c->store->get_account_by_handle($identifier)) { 93 + die { 94 + status => 400, 95 + error => 'InvalidRequest', 96 + message => 'Unable to resolve identity', 97 + } unless _local_account_identity_resolvable($c, $account); 88 98 return { 89 99 did => $account->{did}, 90 100 handle => $account->{handle}, ··· 136 146 }, 137 147 }; 138 148 }); 149 + } 150 + 151 + sub _local_account_identity_resolvable ($c, $account) { 152 + return 0 unless ref($account) eq 'HASH'; 153 + my $did = $account->{did} // q(); 154 + return 1 if _same_did($did, service_did($c->app->settings)); 155 + return 0; 139 156 } 140 157 141 158 sub _resolve_handle_to_did ($c, $handle) { ··· 276 293 277 294 sub _canonical_did ($did) { 278 295 $did = _relaxed_did($did); 279 - $did =~ s/^(did:web:[^:]+):(\d+)$/$1%3A$2/i; 296 + if ($did =~ /\A(did:web:[^:]+):(\d+)(:.*)?\z/i) { 297 + $did = $1 . '%3A' . $2 . ($3 // q()); 298 + } 280 299 return $did; 281 300 } 282 301
+76
script/differential-validate
··· 582 582 'resolveHandle missing-handle semantics match the official reference PDS', 583 583 ); 584 584 585 + note('Comparing resolveDid and resolveIdentity'); 586 + for my $name (sort keys %server) { 587 + my $resolve_did = get_form( 588 + $server{$name}{origin}, 589 + 'com.atproto.identity.resolveDid', 590 + { did => $server{$name}{did} }, 591 + ); 592 + 593 + my $identity_by_did = get_form( 594 + $server{$name}{origin}, 595 + 'com.atproto.identity.resolveIdentity', 596 + { identifier => $server{$name}{did} }, 597 + ); 598 + 599 + my $identity_by_handle = get_form( 600 + $server{$name}{origin}, 601 + 'com.atproto.identity.resolveIdentity', 602 + { identifier => $server{$name}{handle} }, 603 + ); 604 + 605 + my $invalid_handle = get_form( 606 + $server{$name}{origin}, 607 + 'com.atproto.identity.resolveHandle', 608 + { handle => 'bad_handle' }, 609 + ); 610 + 611 + $server{$name}{identity_surface} = { 612 + resolve_did => $resolve_did->is_success 613 + ? { 614 + status => $resolve_did->code, 615 + did_doc => normalize_did_doc( 616 + ($resolve_did->json || {})->{didDoc} || {}, 617 + $server{$name}{did}, 618 + $server{$name}{handle}, 619 + $server{$name}{origin}, 620 + ), 621 + } 622 + : normalize_xrpc_error($resolve_did), 623 + identity_by_did => $identity_by_did->is_success 624 + ? { 625 + status => $identity_by_did->code, 626 + did_match => ((($identity_by_did->json || {})->{did} // q()) eq $server{$name}{did}) ? 1 : 0, 627 + handle_match => ((($identity_by_did->json || {})->{handle} // q()) eq $server{$name}{handle}) ? 1 : 0, 628 + did_doc => normalize_did_doc( 629 + (($identity_by_did->json || {})->{didDoc} || {}), 630 + $server{$name}{did}, 631 + $server{$name}{handle}, 632 + $server{$name}{origin}, 633 + ), 634 + } 635 + : normalize_xrpc_error($identity_by_did), 636 + identity_by_handle => $identity_by_handle->is_success 637 + ? { 638 + status => $identity_by_handle->code, 639 + did_match => ((($identity_by_handle->json || {})->{did} // q()) eq $server{$name}{did}) ? 1 : 0, 640 + handle_match => ((($identity_by_handle->json || {})->{handle} // q()) eq $server{$name}{handle}) ? 1 : 0, 641 + did_doc => normalize_did_doc( 642 + (($identity_by_handle->json || {})->{didDoc} || {}), 643 + $server{$name}{did}, 644 + $server{$name}{handle}, 645 + $server{$name}{origin}, 646 + ), 647 + } 648 + : normalize_xrpc_error($identity_by_handle), 649 + invalid_handle => normalize_xrpc_error($invalid_handle), 650 + }; 651 + } 652 + 653 + if (!same_hash($server{reference}{identity_surface}, $server{perlsky}{identity_surface})) { 654 + note('reference identity surface: ' . encode_json($server{reference}{identity_surface})); 655 + note('perlsky identity surface: ' . encode_json($server{perlsky}{identity_surface})); 656 + fail_check('resolveDid and resolveIdentity local-account semantics match the official reference PDS'); 657 + } else { 658 + pass('resolveDid and resolveIdentity local-account semantics match the official reference PDS'); 659 + } 660 + 585 661 note('Comparing subscribeRepos bootstrap backfill'); 586 662 for my $name (sort keys %server) { 587 663 my $frames = frames_until_quiet("$server{$name}{origin}/xrpc/com.atproto.sync.subscribeRepos?cursor=0");
+39 -1
t/app.t
··· 4 4 use Config (); 5 5 use FindBin qw($Bin); 6 6 use File::Spec; 7 + use File::Temp qw(tempdir); 7 8 use Test::More; 8 9 9 10 BEGIN { ··· 50 51 51 52 $t->get_ok('/xrpc/com.atproto.identity.resolveHandle?handle=not_a_handle') 52 53 ->status_is(400) 53 - ->json_is('/error' => 'InvalidHandle'); 54 + ->json_is('/error' => 'InvalidRequest'); 54 55 55 56 $t->get_ok('/xrpc/com.atproto.identity.resolveDid?did=did:web:127.0.0.1%3A7755') 56 57 ->status_is(200) ··· 59 60 $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { identifier => 'alice', password => 'pw' }) 60 61 ->status_is(401) 61 62 ->json_is('/error' => 'AuthRequired'); 63 + 64 + my $tmp = tempdir(CLEANUP => 1); 65 + my $fresh = Test::Mojo->new(ATProto::PDS->new( 66 + project_root => $root, 67 + settings => { 68 + base_url => 'http://127.0.0.1:7755', 69 + service_did_method => 'did:web', 70 + service_handle_domain => 'localhost', 71 + jwt_secret => 'test-secret', 72 + data_dir => $tmp, 73 + db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 74 + }, 75 + )); 76 + 77 + $fresh->post_ok('/xrpc/com.atproto.server.createAccount' => json => { 78 + handle => 'alice.localhost', 79 + email => 'alice@example.test', 80 + password => 'hunter22', 81 + })->status_is(200); 82 + 83 + my $user_did = $fresh->tx->res->json->{did}; 84 + 85 + $fresh->get_ok("/xrpc/com.atproto.identity.resolveHandle?handle=alice.localhost") 86 + ->status_is(200) 87 + ->json_is('/did' => $user_did); 88 + 89 + $fresh->get_ok("/xrpc/com.atproto.identity.resolveDid?did=$user_did") 90 + ->status_is(400) 91 + ->json_is('/error' => 'InvalidRequest'); 92 + 93 + $fresh->get_ok("/xrpc/com.atproto.identity.resolveIdentity?identifier=$user_did") 94 + ->status_is(400) 95 + ->json_is('/error' => 'InvalidRequest'); 96 + 97 + $fresh->get_ok('/xrpc/com.atproto.identity.resolveIdentity?identifier=alice.localhost') 98 + ->status_is(400) 99 + ->json_is('/error' => 'InvalidRequest'); 62 100 63 101 my $missing_secret_error = eval { 64 102 ATProto::PDS->new(
+4 -5
t/plc-identity.t
··· 153 153 154 154 $t->get_ok('/xrpc/com.atproto.identity.resolveDid' => form => { 155 155 did => $did, 156 - })->status_is(200) 157 - ->json_is('/didDoc/id', $did) 158 - ->json_is('/didDoc/alsoKnownAs/0', 'at://alice.test'); 156 + })->status_is(400) 157 + ->json_is('/error', 'InvalidRequest'); 159 158 160 159 $t->get_ok('/xrpc/com.atproto.identity.getRecommendedDidCredentials' => { 161 160 Authorization => "Bearer $access", ··· 276 275 277 276 $t->get_ok('/xrpc/com.atproto.identity.resolveDid' => form => { 278 277 did => $did, 279 - })->status_is(200) 280 - ->json_is('/didDoc/alsoKnownAs/0', 'at://alice-renamed.test'); 278 + })->status_is(400) 279 + ->json_is('/error', 'InvalidRequest'); 281 280 282 281 done_testing;