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 appview and auth conformance

alice 50447c9c 0ab3b451

+297 -18
+14 -2
lib/ATProto/PDS/API/Builtins.pm
··· 46 46 }); 47 47 48 48 $registry->register('com.atproto.identity.resolveHandle', sub ($c, $endpoint) { 49 - my $handle = lc($c->param('handle') // ''); 49 + my $raw_handle = lc($c->param('handle') // q()); 50 + my $service_handle = lc($c->config_value('service_handle_domain', 'localhost')); 51 + if ($raw_handle eq $service_handle) { 52 + return { 53 + did => service_did($c->app->settings), 54 + }; 55 + } 56 + 57 + my $handle = normalize_handle($raw_handle, undef, { no_append => 1 }); 58 + die { 59 + status => 400, 60 + error => 'InvalidHandle', 61 + message => 'Handle is invalid', 62 + } unless defined $handle && length $handle; 50 63 if (my $account = $c->store->get_account_by_handle($handle)) { 51 64 return { did => $account->{did} }; 52 65 } 53 66 54 - my $service_handle = lc($c->config_value('service_handle_domain', 'localhost')); 55 67 if ($handle eq $service_handle) { 56 68 return { 57 69 did => service_did($c->app->settings),
+7 -2
lib/ATProto/PDS/API/Server.pm
··· 194 194 if (($authn->{kind} // q()) eq 'app_password' && is_repo_takedown($c, $account->{did})) { 195 195 xrpc_error(401, 'AuthRequired', 'Invalid identifier or password'); 196 196 } 197 - assert_login_allowed($c, $account, allow_takedown => $body->{allowTakendown}); 197 + assert_login_allowed( 198 + $c, 199 + $account, 200 + allow_takedown => $body->{allowTakendown}, 201 + allow_deactivated => 1, 202 + ); 198 203 return _issue_session($c, $account, 199 204 kind => $authn->{kind}, 200 205 scope => $authn->{scope}, ··· 218 223 219 224 $registry->register('com.atproto.server.refreshSession', sub ($c, $endpoint) { 220 225 my (undef, $account, $session) = require_auth($c, audience => TOKEN_AUD_REFRESH); 221 - assert_login_allowed($c, $account); 226 + assert_login_allowed($c, $account, allow_deactivated => 1); 222 227 my $rotated = $c->store->rotate_session($session->{id}); 223 228 xrpc_error(401, 'ExpiredToken', 'Refresh session has already been revoked') unless $rotated; 224 229 return _session_response($c, $account, $rotated);
+3
lib/ATProto/PDS/API/Sync.pm
··· 120 120 close($fh); 121 121 $c->res->headers->content_type($blob->{mime_type} || 'application/octet-stream'); 122 122 $c->res->headers->header('Cross-Origin-Resource-Policy' => 'cross-origin'); 123 + $c->res->headers->header('X-Content-Type-Options' => 'nosniff'); 124 + $c->res->headers->header('Content-Disposition' => 'attachment; filename="' . ($c->param('cid') // q()) . '"'); 125 + $c->res->headers->header('Content-Security-Policy' => q{default-src 'none'; sandbox}); 123 126 $c->observe_blob_egress($blob->{mime_type}, length($bytes)); 124 127 $c->render(data => $bytes); 125 128 return;
+3 -1
lib/ATProto/PDS/ServiceProxy/Posts.pm
··· 8 8 use Exporter 'import'; 9 9 10 10 use ATProto::PDS::API::Util qw(iso8601 resolve_repo xrpc_error); 11 - use ATProto::PDS::Moderation qw(parse_at_uri); 11 + use ATProto::PDS::Moderation qw(assert_record_readable assert_repo_readable parse_at_uri); 12 12 13 13 our @EXPORT_OK = qw( 14 14 _non_negative_int_param ··· 42 42 }; 43 43 xrpc_error(404, 'RecordNotFound', 'Record was not found') 44 44 unless $collection eq 'app.bsky.feed.post'; 45 + assert_repo_readable($c, $account); 45 46 my $canonical_uri = 'at://' . $account->{did} . '/' . $collection . '/' . $rkey; 46 47 my $local_post_index = $c->stash('local_post_index'); 47 48 if ($local_post_index && $local_post_index->{posts}{$canonical_uri}) { ··· 63 64 ); 64 65 my $row = $c->store->get_record($account->{did}, $collection, $rkey); 65 66 xrpc_error(404, 'RecordNotFound', 'Record was not found') unless $row; 67 + assert_record_readable($c, $canonical_uri); 66 68 my $resolved = [ $account, $row ]; 67 69 $cache->{$uri} = $resolved; 68 70 $cache->{$canonical_uri} = $resolved;
+88 -4
lib/ATProto/PDS/ServiceProxy/Preferences.pm
··· 7 7 8 8 use Exporter 'import'; 9 9 use JSON::PP (); 10 + use Time::Piece (); 10 11 11 12 use ATProto::PDS::API::Server qw(require_auth); 12 13 use ATProto::PDS::API::Util qw(xrpc_error); ··· 25 26 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.getPreferences expects GET') 26 27 unless $c->req->method eq 'GET'; 27 28 28 - my (undef, $account) = require_auth( 29 + my (undef, $account, $session) = require_auth( 29 30 $c, 30 31 audience => TOKEN_AUD_ACCESS, 31 32 required_permission => { ··· 34 35 lxm => 'app.bsky.actor.getPreferences', 35 36 }, 36 37 ); 37 - my $preferences = $c->store->list_preferences($account->{did}, 'app.bsky'); 38 + my $preferences = _visible_preferences( 39 + $c->store->list_preferences($account->{did}, 'app.bsky'), 40 + session => $session, 41 + ); 38 42 $c->render(json => { preferences => $preferences }); 39 43 return 200; 40 44 } ··· 43 47 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.putPreferences expects POST') 44 48 unless $c->req->method eq 'POST'; 45 49 46 - my (undef, $account) = require_auth( 50 + my (undef, $account, $session) = require_auth( 47 51 $c, 48 52 audience => TOKEN_AUD_ACCESS, 49 53 required_permission => { ··· 62 66 unless ref($pref) eq 'HASH'; 63 67 xrpc_error(400, 'InvalidRequest', 'preference entries must include $type') 64 68 unless defined($pref->{'$type'}) && length($pref->{'$type'}); 69 + xrpc_error(400, 'InvalidRequest', 'Some preferences are not in the app.bsky namespace') 70 + unless _pref_matches_namespace('app.bsky', $pref->{'$type'}); 71 + xrpc_error(400, 'InvalidRequest', 'Do not have authorization to set preferences: app.bsky.actor.defs#personalDetailsPref') 72 + if _pref_requires_full_access($pref->{'$type'}) && _session_is_app_password($session); 65 73 } 66 74 67 - $c->store->put_preferences($account->{did}, 'app.bsky', $preferences); 75 + my @stored = grep { !_pref_is_read_only($_->{'$type'} // q()) } @$preferences; 76 + $c->store->put_preferences($account->{did}, 'app.bsky', \@stored); 68 77 $c->render(json => {}); 69 78 return 200; 70 79 } ··· 103 112 my $body = $c->req->json || {}; 104 113 xrpc_error(400, 'InvalidRequest', 'notification preferences body must be an object') 105 114 unless ref($body) eq 'HASH'; 115 + _validate_notification_preferences_patch($body); 106 116 107 117 my $preferences = { 108 118 %{ $self->_load_notification_preferences($c, $account->{did}) }, ··· 111 121 $c->store->put_notification_preferences($account->{did}, $preferences); 112 122 $c->render(json => { preferences => $preferences }); 113 123 return 200; 124 + } 125 + 126 + sub _visible_preferences ($preferences, %opts) { 127 + my @prefs = map { { %$_ } } @{ $preferences || [] }; 128 + my $declared_age = _declared_age_pref(\@prefs); 129 + if (_session_is_app_password($opts{session})) { 130 + @prefs = grep { 131 + my $type = $_->{'$type'} // q(); 132 + $type ne 'app.bsky.actor.defs#personalDetailsPref'; 133 + } @prefs; 134 + } 135 + 136 + push @prefs, $declared_age if $declared_age; 137 + return \@prefs; 138 + } 139 + 140 + sub _declared_age_pref ($preferences) { 141 + my ($personal) = grep { 142 + ($_->{'$type'} // q()) eq 'app.bsky.actor.defs#personalDetailsPref' 143 + } @$preferences; 144 + return undef unless $personal && defined($personal->{birthDate}) && !ref($personal->{birthDate}); 145 + my ($year, $month, $day) = ($personal->{birthDate} =~ /\A(\d{4})-(\d{2})-(\d{2})/); 146 + return undef unless defined $year; 147 + 148 + my $today = Time::Piece::gmtime(time); 149 + my $age = $today->year - $year; 150 + $age-- if ($today->mon < $month) || ($today->mon == $month && $today->mday < $day); 151 + 152 + return { 153 + '$type' => 'app.bsky.actor.defs#declaredAgePref', 154 + isOverAge13 => $age >= 13 ? JSON::PP::true : JSON::PP::false, 155 + isOverAge16 => $age >= 16 ? JSON::PP::true : JSON::PP::false, 156 + isOverAge18 => $age >= 18 ? JSON::PP::true : JSON::PP::false, 157 + }; 158 + } 159 + 160 + sub _pref_matches_namespace ($namespace, $type) { 161 + return $type eq $namespace || $type =~ /^\Q$namespace\E\./; 162 + } 163 + 164 + sub _pref_requires_full_access ($type) { 165 + return $type eq 'app.bsky.actor.defs#personalDetailsPref'; 166 + } 167 + 168 + sub _pref_is_read_only ($type) { 169 + return $type eq 'app.bsky.actor.defs#declaredAgePref'; 170 + } 171 + 172 + sub _session_is_app_password ($session) { 173 + return defined($session) && (($session->{kind} // q()) eq 'app_password'); 174 + } 175 + 176 + sub _validate_notification_preferences_patch ($body) { 177 + my $defaults = _default_notification_preferences(); 178 + for my $category (keys %$body) { 179 + xrpc_error(400, 'InvalidRequest', "Unsupported notification preference category: $category") 180 + unless exists $defaults->{$category}; 181 + my $value = $body->{$category}; 182 + xrpc_error(400, 'InvalidRequest', "Notification preference $category must be an object") 183 + unless ref($value) eq 'HASH'; 184 + my %allowed = map { $_ => 1 } keys %{ $defaults->{$category} }; 185 + for my $key (keys %$value) { 186 + xrpc_error(400, 'InvalidRequest', "Unsupported notification preference field: $category.$key") 187 + unless $allowed{$key}; 188 + if ($key eq 'include') { 189 + xrpc_error(400, 'InvalidRequest', "Notification preference $category.$key must be a string") 190 + if ref($value->{$key}); 191 + next; 192 + } 193 + xrpc_error(400, 'InvalidRequest', "Notification preference $category.$key must be a boolean") 194 + unless JSON::PP::is_bool($value->{$key}); 195 + } 196 + } 197 + return 1; 114 198 } 115 199 116 200 sub _load_notification_preferences ($self, $c, $did) {
-1
lib/ATProto/PDS/ServiceProxy/Profile.pm
··· 81 81 did => $account->{did}, 82 82 handle => $account->{handle}, 83 83 associated => $self->_profile_associated, 84 - labels => [], 85 84 createdAt => iso8601($account->{created_at}), 86 85 }; 87 86 if ($viewer) {
+28 -3
lib/ATProto/PDS/ServiceProxy/Threads.pm
··· 36 36 37 37 my $account = resolve_repo($c, $actor) or return undef; 38 38 my $viewer = $self->_optional_auth_account($c, 'app.bsky.feed.getAuthorFeed'); 39 + return undef unless $viewer && ($viewer->{did} // q()) eq ($account->{did} // q()); 39 40 my $limit = $c->param('limit') // 50; 40 41 $limit = 1 if $limit < 1; 41 42 $limit = 100 if $limit > 100; ··· 67 68 my @uris = grep { defined($_) && length($_) } $c->every_param('uris'); 68 69 xrpc_error(400, 'InvalidRequest', 'uris is required') unless @uris; 69 70 70 - my @resolved = map { $self->_resolve_local_post_uri($c, $_) } @uris; 71 - return undef if grep { !defined $_ } @resolved; 71 + my @resolved; 72 + my %seen_uri; 73 + for my $uri (@uris) { 74 + my $resolved = eval { $self->_resolve_local_post_uri($c, $uri) }; 75 + if (my $err = $@) { 76 + next if ref($err) eq 'HASH' 77 + && ($err->{status} // 0) == 404 78 + && ($err->{error} // q()) eq 'RecordNotFound'; 79 + die $err; 80 + } 81 + return undef unless defined $resolved; 82 + my ($account, $row) = @$resolved; 83 + my $canonical_uri = $self->_post_uri($account, $row); 84 + next if $seen_uri{$canonical_uri}++; 85 + push @resolved, $resolved; 86 + } 72 87 73 88 my $viewer = $self->_optional_auth_account($c, 'app.bsky.feed.getPosts'); 74 89 my @posts = map { ··· 91 106 my $resolved = $self->_resolve_local_post_uri($c, $uri) or return undef; 92 107 my ($account, $row) = @$resolved; 93 108 my $viewer = $self->_optional_auth_account($c, 'app.bsky.feed.getPostThread'); 109 + return undef unless $viewer && ($viewer->{did} // q()) eq ($account->{did} // q()); 110 + return undef if _thread_requires_upstream($self, $c, $row); 94 111 my $profile_value = $self->_profile_record_value($c, $account); 95 112 my $depth = $self->_non_negative_int_param($c, 'depth', 6); 96 113 my $parent_height = $self->_non_negative_int_param($c, 'parentHeight', 80); ··· 125 142 author => $self->_profile_view_basic($c, $account, $profile_value, $viewer), 126 143 record => $row->{value}, 127 144 indexedAt => $self->_post_indexed_at($row), 128 - labels => [], 129 145 }; 130 146 if ($depth < 2) { 131 147 my $embed = $self->_post_embed_view($c, $account, $row->{value}, $viewer, $depth + 1); ··· 134 150 my $viewer_state = $self->_post_counts_and_viewer($c, $uri, $viewer)->{viewer} || {}; 135 151 $post->{viewer} = $viewer_state if %$viewer_state; 136 152 return $post; 153 + } 154 + 155 + sub _thread_requires_upstream ($self, $c, $row) { 156 + for my $uri ($self->_reply_parent_uri($row), $self->_quoted_uri($row->{value})) { 157 + next unless defined $uri && length $uri; 158 + my $resolved = eval { $self->_resolve_local_post_uri($c, $uri) }; 159 + return 1 if !$resolved || $@; 160 + } 161 + return 0; 137 162 } 138 163 139 164 sub _thread_view ($self, $c, $account, $row, $profile_value = undef, $viewer = undef, $depth = 6, $parent_height = 80) {
+4
t/app.t
··· 48 48 ->status_is(200) 49 49 ->json_is('/did' => 'did:web:127.0.0.1%3A7755'); 50 50 51 + $t->get_ok('/xrpc/com.atproto.identity.resolveHandle?handle=not_a_handle') 52 + ->status_is(400) 53 + ->json_is('/error' => 'InvalidHandle'); 54 + 51 55 $t->get_ok('/xrpc/com.atproto.identity.resolveDid?did=did:web:127.0.0.1%3A7755') 52 56 ->status_is(200) 53 57 ->json_is('/didDoc/id' => 'did:web:127.0.0.1%3A7755');
+6
t/external-surface.t
··· 119 119 cid => $blob_cid, 120 120 ))->status_is(200) 121 121 ->header_is('Cross-Origin-Resource-Policy' => 'cross-origin') 122 + ->header_is('X-Content-Type-Options' => 'nosniff') 123 + ->header_like('Content-Disposition' => qr/\Aattachment; filename="/) 124 + ->header_is('Content-Security-Policy' => "default-src 'none'; sandbox") 122 125 ->content_type_is('text/plain') 123 126 ->content_is('blob-bytes'); 124 127 ··· 166 169 cid => $blob_cid, 167 170 ))->status_is(200) 168 171 ->header_is('Cross-Origin-Resource-Policy' => 'cross-origin') 172 + ->header_is('X-Content-Type-Options' => 'nosniff') 173 + ->header_like('Content-Disposition' => qr/\Aattachment; filename="/) 174 + ->header_is('Content-Security-Policy' => "default-src 'none'; sandbox") 169 175 ->content_type_is('text/plain') 170 176 ->content_is('blob-bytes'); 171 177
+1 -1
t/metrics.t
··· 204 204 ); 205 205 like( 206 206 $metrics, 207 - qr/perlsky_service_proxy_local_post_resolution_total\{source="index_cache"\} [1-9]\d*\b/, 207 + qr/perlsky_service_proxy_local_post_resolution_total\{source="request_cache"\} [1-9]\d*\b/, 208 208 'local post-resolution source counters are exported', 209 209 ); 210 210 like(
+35
t/server-auth.t
··· 294 294 })->status_is(200) 295 295 ->json_has('/token'); 296 296 297 + $t->post_ok('/xrpc/com.atproto.admin.updateSubjectStatus' => { 298 + Authorization => $admin_auth, 299 + } => json => { 300 + subject => { 301 + '$type' => 'com.atproto.admin.defs#repoRef', 302 + did => $did, 303 + }, 304 + takedown => { applied => JSON::PP::false }, 305 + })->status_is(200); 306 + 307 + $t->post_ok('/xrpc/com.atproto.server.deactivateAccount' => { 308 + Authorization => "Bearer $replacement_access", 309 + } => json => {})->status_is(200); 310 + 311 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 312 + identifier => 'alice.localhost', 313 + password => 'password123', 314 + })->status_is(200) 315 + ->json_is('/did' => $did) 316 + ->json_is('/active' => JSON::PP::false) 317 + ->json_is('/status' => 'deactivated'); 318 + 319 + my $deactivated_session = $t->tx->res->json; 320 + 321 + $t->post_ok('/xrpc/com.atproto.server.refreshSession' => { 322 + Authorization => "Bearer $deactivated_session->{refreshJwt}", 323 + } => json => {})->status_is(200) 324 + ->json_is('/did' => $did) 325 + ->json_is('/active' => JSON::PP::false) 326 + ->json_is('/status' => 'deactivated'); 327 + 328 + $t->post_ok('/xrpc/com.atproto.server.activateAccount' => { 329 + Authorization => "Bearer $replacement_access", 330 + } => json => {})->status_is(200); 331 + 297 332 my $legacy_tmp = File::Spec->catdir($root, 'data', 'tmp-tests', 'server-auth-legacy'); 298 333 remove_tree($legacy_tmp) if -d $legacy_tmp; 299 334 my $legacy_t = Test::Mojo->new(ATProto::PDS->new(
+6
t/service-proxy-local.t
··· 82 82 $self->{get_record_calls}{"$did|$collection|$rkey"}++; 83 83 return $self->{records}{"$did|$collection|$rkey"}; 84 84 } 85 + 86 + sub get_subject_status { 87 + my ($self, $key) = @_; 88 + $self->{get_subject_status_calls}{$key}++; 89 + return $self->{subject_status}{$key}; 90 + } 85 91 } 86 92 87 93 {
+102 -4
t/service-proxy.t
··· 23 23 use Crypt::PK::ECC; 24 24 use IO::Socket::INET; 25 25 use Mojo::Server::Daemon; 26 + use Mojo::URL; 26 27 use Mojo::UserAgent; 27 28 use Mojo::Util qw(url_unescape); 28 29 use Mojolicious; ··· 232 233 ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#savedFeedsPref') 233 234 ->json_is('/preferences/0/pinned/0' => 'at://did:plc:feed/app.bsky.feed.generator/demo'); 234 235 236 + $t->post_ok('/xrpc/app.bsky.actor.putPreferences' => { 237 + Authorization => "Bearer $access", 238 + } => json => { 239 + preferences => [{ 240 + '$type' => 'com.atproto.server.defs#unknown', 241 + }], 242 + })->status_is(400) 243 + ->json_is('/error' => 'InvalidRequest'); 244 + 245 + $t->post_ok('/xrpc/app.bsky.actor.putPreferences' => { 246 + Authorization => "Bearer $access", 247 + } => json => { 248 + preferences => [{ 249 + '$type' => 'app.bsky.actor.defs#personalDetailsPref', 250 + birthDate => '1970-01-01T00:00:00.000Z', 251 + }], 252 + })->status_is(200); 253 + 254 + $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => { 255 + Authorization => "Bearer $access", 256 + })->status_is(200) 257 + ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#personalDetailsPref') 258 + ->json_is('/preferences/1/$type' => 'app.bsky.actor.defs#declaredAgePref') 259 + ->json_is('/preferences/1/isOverAge18' => JSON::PP::true); 260 + 261 + $t->post_ok('/xrpc/com.atproto.server.createAppPassword' => { 262 + Authorization => "Bearer $access", 263 + } => json => { 264 + name => 'prefs-device', 265 + })->status_is(200) 266 + ->json_has('/password'); 267 + 268 + my $prefs_app_password = $t->tx->res->json->{password}; 269 + 270 + $t->post_ok('/xrpc/com.atproto.server.createSession' => json => { 271 + identifier => 'alice.localhost', 272 + password => $prefs_app_password, 273 + })->status_is(200) 274 + ->json_has('/accessJwt'); 275 + 276 + my $prefs_app_access = $t->tx->res->json->{accessJwt}; 277 + 278 + $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => { 279 + Authorization => "Bearer $prefs_app_access", 280 + })->status_is(200) 281 + ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#declaredAgePref') 282 + ->json_is('/preferences/0/isOverAge18' => JSON::PP::true); 283 + ok( 284 + !scalar(grep { ($_->{'$type'} // q()) eq 'app.bsky.actor.defs#personalDetailsPref' } @{ $t->tx->res->json->{preferences} || [] }), 285 + 'app password preference reads hide personalDetailsPref', 286 + ); 287 + 288 + $t->post_ok('/xrpc/app.bsky.actor.putPreferences' => { 289 + Authorization => "Bearer $prefs_app_access", 290 + } => json => { 291 + preferences => [{ 292 + '$type' => 'app.bsky.actor.defs#personalDetailsPref', 293 + birthDate => '1970-01-01T00:00:00.000Z', 294 + }], 295 + })->status_is(400) 296 + ->json_is('/error' => 'InvalidRequest'); 297 + 235 298 $t->get_ok('/xrpc/app.bsky.notification.getPreferences' => { 236 299 Authorization => "Bearer $access", 237 300 })->status_is(200) ··· 262 325 ->json_is('/preferences/verified/list' => JSON::PP::false) 263 326 ->json_is('/preferences/verified/push' => JSON::PP::false); 264 327 328 + $t->post_ok('/xrpc/app.bsky.notification.putPreferencesV2' => { 329 + Authorization => "Bearer $access", 330 + } => json => { 331 + mystery => { 332 + push => JSON::PP::true, 333 + }, 334 + })->status_is(400) 335 + ->json_is('/error' => 'InvalidRequest'); 336 + 265 337 $t->get_ok('/xrpc/app.bsky.notification.getPreferences' => { 266 338 Authorization => "Bearer $access", 267 339 })->status_is(200) ··· 355 427 $t->get_ok("/xrpc/app.bsky.feed.getAuthorFeed?actor=$bob_did&limit=10" => { 356 428 Authorization => "Bearer $access", 357 429 })->status_is(200) 358 - ->json_is('/feed/0/post/author/viewer/following' => "at://$did/app.bsky.graph.follow/follow-bob") 359 - ->json_is('/feed/0/post/author/viewer/followedBy' => "at://$bob_did/app.bsky.graph.follow/follow-alice"); 430 + ->json_is('/nsid' => 'app.bsky.feed.getAuthorFeed'); 431 + ok($t->tx->res->json->{auth}, 'non-owner author feed proxies upstream instead of synthesizing local appview state'); 360 432 361 433 $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 362 434 Authorization => "Bearer $access", ··· 390 462 ->json_is('/feed/0/post/record/text' => 'browser smoke post') 391 463 ->json_is('/feed/0/post/author/associated/chat/allowIncoming' => 'all') 392 464 ->json_is('/feed/0/post/author/associated/activitySubscription/allowSubscriptions' => 'followers') 393 - ->json_is('/feed/0/post/author/labels' => []) 394 465 ->json_has('/feed/0/post/author/createdAt'); 466 + ok(!exists($t->tx->res->json->{feed}[0]{post}{author}{labels}), 'local author view omits non-authoritative labels'); 395 467 ok(!exists($t->tx->res->json->{feed}[0]{post}{bookmarkCount}), 'local post view omits non-authoritative bookmarkCount'); 396 468 ok(!exists($t->tx->res->json->{feed}[0]{post}{replyCount}), 'local post view omits non-authoritative replyCount'); 397 469 ok(!exists($t->tx->res->json->{feed}[0]{post}{likeCount}), 'local post view omits non-authoritative likeCount'); ··· 461 533 })->status_is(200); 462 534 is_deeply($t->tx->res->json, $reply_thread, 'handle-form local post URIs return the same thread payload'); 463 535 536 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 537 + Authorization => "Bearer $access", 538 + } => json => { 539 + repo => $did, 540 + collection => 'app.bsky.feed.post', 541 + rkey => 'reply-remote-parent', 542 + record => { 543 + '$type' => 'app.bsky.feed.post', 544 + text => 'reply to remote parent', 545 + reply => { 546 + root => { uri => 'at://did:plc:remote/app.bsky.feed.post/root', cid => 'bafyremote-root' }, 547 + parent => { uri => 'at://did:plc:remote/app.bsky.feed.post/parent', cid => 'bafyremote-parent' }, 548 + }, 549 + createdAt => '2026-03-10T18:03:00Z', 550 + }, 551 + })->status_is(200) 552 + ->json_has('/cid'); 553 + 554 + my $remote_parent_reply = $t->tx->res->json; 555 + 556 + $t->get_ok('/xrpc/app.bsky.feed.getPostThread?uri=' . _uri_escape($remote_parent_reply->{uri}) => { 557 + Authorization => "Bearer $access", 558 + })->status_is(200) 559 + ->json_is('/nsid' => 'app.bsky.feed.getPostThread'); 560 + ok($t->tx->res->json->{auth}, 'local thread with remote parent proxies upstream instead of dropping the parent tree'); 561 + 464 562 $t->get_ok('/xrpc/app.bsky.notification.listNotifications?limit=40' => { 465 563 Authorization => "Bearer $access", 466 564 })->status_is(200) ··· 498 596 Authorization => "Bearer $access", 499 597 'Atproto-Proxy' => 'did:web:appview.test#bsky_appview', 500 598 })->status_is(200) 501 - ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#savedFeedsPref'); 599 + ->json_is('/preferences/0/$type' => 'app.bsky.actor.defs#personalDetailsPref'); 502 600 503 601 $t->get_ok('/xrpc/example.unsupported.method') 504 602 ->status_is(404)