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 repo and invite read edge semantics

alice 00a4064f adfe5eeb

+85 -19
+2 -1
docs/TEST_AUDIT.md
··· 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=61, Tests=3094` 16 + - latest full green result in the realigned Meridian worktree: `Files=61, Tests=3110` 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` ··· 76 76 - `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`. 77 77 - 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. 78 78 - 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. 79 + - Missing-repo read paths now match the official runtime more closely: `describeRepo`, `sync.getLatestCommit`, `sync.getHead`, and `sync.getRepoStatus` report `400 RepoNotFound`, while `listRecords` reports `400 InvalidRequest` / `Could not find repo: ...`. 79 80 - `com.atproto.repo.getRecord` must honor `cid` when present, and `putRecord` / `deleteRecord` must actually enforce `swapRecord`; those negative edges are now covered directly. 80 81 - `com.atproto.repo.createRecord` follows the reference runtime by ignoring a stray `swapRecord` field, and direct reference coverage now pins `putRecord` / `deleteRecord` `swapCommit` and `swapRecord` mismatch semantics explicitly. 81 82 - App-password sessions follow the official runtime more closely than the older local assumptions did: access-token scopes use the `com.atproto.appPass` / `com.atproto.appPassPrivileged` names, standard app-password sessions may list app passwords, privileged-only `getServiceAuth` failures report `InvalidRequest`, and revoked refresh tokens on `refreshSession` fail with `400 ExpiredToken`.
+17 -3
lib/ATProto/PDS/API/Repo.pm
··· 27 27 28 28 sub register_repo_handlers ($registry, $app) { 29 29 $registry->register('com.atproto.repo.describeRepo', sub ($c, $endpoint) { 30 - my $account = _readable_repo($c, $c->param('repo')); 30 + my $account = _readable_repo( 31 + $c, 32 + $c->param('repo'), 33 + missing_status => 400, 34 + ); 31 35 my $did_doc = _describe_repo_did_doc($c, $account); 32 36 33 37 return { ··· 227 231 228 232 sub _readable_repo ($c, $repo, %args) { 229 233 my $account = resolve_repo($c, $repo); 230 - xrpc_error(404, 'RepoNotFound', 'Repository was not found') unless $account; 231 - assert_repo_readable($c, $account, %args); 234 + xrpc_error( 235 + $args{missing_status} // $args{status} // 404, 236 + $args{missing_error} // $args{error} // 'RepoNotFound', 237 + $args{missing_message} // $args{message} // 'Repository was not found', 238 + ) unless $account; 239 + assert_repo_readable( 240 + $c, 241 + $account, 242 + (defined($args{readable_status} // $args{status}) ? (status => ($args{readable_status} // $args{status})) : ()), 243 + (defined($args{readable_error} // $args{error}) ? (error => ($args{readable_error} // $args{error})) : ()), 244 + (defined($args{readable_message} // $args{message}) ? (message => ($args{readable_message} // $args{message})) : ()), 245 + ); 232 246 return $account; 233 247 } 234 248
+3 -4
lib/ATProto/PDS/API/Sync.pm
··· 27 27 28 28 sub register_sync_handlers ($registry, $app) { 29 29 $registry->register('com.atproto.sync.getLatestCommit', sub ($c, $endpoint) { 30 - my $account = _readable_repo_by_did($c); 30 + my $account = _readable_repo_by_did($c, missing_status => 400); 31 31 my $head = _repo_head_or_error($c, $account->{did}); 32 32 return { 33 33 cid => $head->{commit_cid}, ··· 38 38 $registry->register('com.atproto.sync.getHead', sub ($c, $endpoint) { 39 39 my $account = _readable_repo_by_did( 40 40 $c, 41 - missing_error => 'HeadNotFound', 42 - missing_message => 'Repository head was not found', 41 + missing_status => 400, 43 42 readable_error => 'HeadNotFound', 44 43 readable_message => 'Repository head was not found', 45 44 ); ··· 55 54 }); 56 55 57 56 $registry->register('com.atproto.sync.getRepoStatus', sub ($c, $endpoint) { 58 - my $account = _repo_by_did_or_error($c); 57 + my $account = _repo_by_did_or_error($c, missing_status => 400); 59 58 my $active = (!defined($account->{deleted_at}) && !defined($account->{deactivated_at}) && !is_repo_takedown($c, $account->{did})) 60 59 ? JSON::PP::true 61 60 : JSON::PP::false;
+7 -7
lib/ATProto/PDS/Store/SQLite/Invites.pm
··· 64 64 my @bind; 65 65 my @where; 66 66 my $inner_sql = q{ 67 - SELECT invite_codes.*, COUNT(invite_code_uses.code) AS use_count_consumed 67 + SELECT invite_codes.*, MIN(invite_codes.rowid) AS invite_rowid, COUNT(invite_code_uses.code) AS use_count_consumed 68 68 FROM invite_codes 69 69 LEFT JOIN invite_code_uses ON invite_code_uses.code = invite_codes.code 70 70 GROUP BY invite_codes.code ··· 80 80 $sql .= q{ ORDER BY invite_codes.use_count_consumed DESC, invite_codes.code ASC}; 81 81 } else { 82 82 if (defined $cursor && length $cursor) { 83 - my ($cursor_created_at, $cursor_code) = ATProto::PDS::Store::SQLite::_parse_recent_cursor($cursor); 84 - push @where, q{(invite_codes.created_at < ? OR (invite_codes.created_at = ? AND invite_codes.code > ?))}; 85 - push @bind, $cursor_created_at, $cursor_created_at, $cursor_code; 83 + my ($cursor_created_at, $cursor_rowid) = ATProto::PDS::Store::SQLite::_parse_recent_cursor($cursor); 84 + push @where, q{(invite_codes.created_at < ? OR (invite_codes.created_at = ? AND invite_codes.invite_rowid > ?))}; 85 + push @bind, $cursor_created_at, $cursor_created_at, $cursor_rowid; 86 86 } 87 87 $sql .= q{ WHERE } . join(q{ AND }, @where) if @where; 88 - $sql .= q{ ORDER BY invite_codes.created_at DESC, invite_codes.code ASC}; 88 + $sql .= q{ ORDER BY invite_codes.created_at DESC, invite_codes.invite_rowid ASC}; 89 89 } 90 90 $sql .= q{ LIMIT ?}; 91 91 push @bind, $limit + 1; ··· 95 95 $limit, 96 96 $sort eq 'usage' 97 97 ? sub ($row) { ATProto::PDS::Store::SQLite::_usage_cursor($row->{use_count_consumed}, $row->{code}) } 98 - : sub ($row) { ATProto::PDS::Store::SQLite::_recent_cursor($row->{created_at}, $row->{code}) }, 98 + : sub ($row) { ATProto::PDS::Store::SQLite::_recent_cursor($row->{created_at}, $row->{invite_rowid}) }, 99 99 ); 100 100 if (!defined($page->{cursor}) && @{ $page->{items} || [] }) { 101 101 my $last = $page->{items}[-1]; 102 102 $page->{cursor} = $sort eq 'usage' 103 103 ? ATProto::PDS::Store::SQLite::_usage_cursor($last->{use_count_consumed}, $last->{code}) 104 - : ATProto::PDS::Store::SQLite::_recent_cursor($last->{created_at}, $last->{code}); 104 + : ATProto::PDS::Store::SQLite::_recent_cursor($last->{created_at}, $last->{invite_rowid}); 105 105 } 106 106 return $page; 107 107 }
+33 -2
script/differential-validate
··· 1873 1873 'describeRepo matches the official reference PDS semantics', 1874 1874 ); 1875 1875 1876 + note('Comparing missing-repo read semantics'); 1877 + for my $name (sort keys %server) { 1878 + my $missing_did = 'did:web:missing.test'; 1879 + $server{$name}{missing_repo_reads} = { 1880 + describe_repo => normalize_xrpc_error(get_form($server{$name}{origin}, 'com.atproto.repo.describeRepo', { 1881 + repo => $missing_did, 1882 + })), 1883 + list_records => normalize_xrpc_error(get_form($server{$name}{origin}, 'com.atproto.repo.listRecords', { 1884 + repo => $missing_did, 1885 + collection => 'app.bsky.feed.post', 1886 + })), 1887 + latest_commit => normalize_xrpc_error(get_form($server{$name}{origin}, 'com.atproto.sync.getLatestCommit', { 1888 + did => $missing_did, 1889 + })), 1890 + head => normalize_xrpc_error(get_form($server{$name}{origin}, 'com.atproto.sync.getHead', { 1891 + did => $missing_did, 1892 + })), 1893 + repo_status => normalize_xrpc_error(get_form($server{$name}{origin}, 'com.atproto.sync.getRepoStatus', { 1894 + did => $missing_did, 1895 + })), 1896 + }; 1897 + } 1898 + 1899 + if (!same_hash($server{reference}{missing_repo_reads}, $server{perlsky}{missing_repo_reads})) { 1900 + note('reference missing repo reads: ' . encode_json($server{reference}{missing_repo_reads})); 1901 + note('perlsky missing repo reads: ' . encode_json($server{perlsky}{missing_repo_reads})); 1902 + fail_check('missing-repo read semantics match the official reference PDS'); 1903 + } else { 1904 + pass('missing-repo read semantics match the official reference PDS'); 1905 + } 1906 + 1876 1907 note('Comparing listRecords'); 1877 1908 for my $name (sort keys %server) { 1878 1909 my $res = get_form($server{$name}{origin}, 'com.atproto.repo.listRecords', { ··· 2882 2913 my %code_label = ( 2883 2914 ($used_code // q()) => 'used', 2884 2915 ($unused_code // q()) => 'unused', 2885 - ($account_codes[0] // q()) => 'account_first', 2886 - ($account_codes[1] // q()) => 'account_second', 2916 + ($account_codes[0] // q()) => 'account', 2917 + ($account_codes[1] // q()) => 'account', 2887 2918 ); 2888 2919 my %did_label = ( 2889 2920 ($invite_first_did // q()) => 'first',
+2 -2
t/invite-admin.t
··· 113 113 $t->get_ok('/xrpc/com.atproto.admin.getInviteCodes?sort=recent&limit=2' => { 114 114 Authorization => $admin_auth, 115 115 })->status_is(200) 116 - ->json_is('/codes/0/code' => 'perlsky-audit-tie-a') 117 - ->json_is('/codes/1/code' => 'perlsky-audit-tie-b') 116 + ->json_is('/codes/0/code' => 'perlsky-audit-tie-b') 117 + ->json_is('/codes/1/code' => 'perlsky-audit-tie-a') 118 118 ->json_has('/cursor'); 119 119 120 120 my $recent_cursor = $t->tx->res->json->{cursor};
+21
t/repo-api.t
··· 91 91 ->json_is('/didDoc/id' => $did) 92 92 ->json_is('/handleIsCorrect' => JSON::PP::true); 93 93 94 + $t->get_ok('/xrpc/com.atproto.repo.describeRepo?repo=did:web:missing.test') 95 + ->status_is(400) 96 + ->json_is('/error' => 'RepoNotFound'); 97 + 94 98 my $account = $t->app->store->get_account_by_did($did); 95 99 my $original_did_doc = $account->{did_doc}; 96 100 my %stale_did_doc = %{$original_did_doc}; ··· 324 328 ->json_is('/records/0/value/text' => 'put created this record') 325 329 ->json_is('/records/1/value/text' => 'hello from swapped perl'); 326 330 331 + $t->get_ok('/xrpc/com.atproto.repo.listRecords?repo=did:web:missing.test&collection=app.bsky.feed.post') 332 + ->status_is(400) 333 + ->json_is('/error' => 'InvalidRequest') 334 + ->json_is('/message' => 'Could not find repo: did:web:missing.test'); 335 + 327 336 $t->get_ok("/xrpc/com.atproto.sync.getLatestCommit?did=$did") 328 337 ->status_is(200) 329 338 ->json_like('/cid' => qr/\Ab/) 330 339 ->json_has('/rev'); 331 340 my $latest_commit_cid = $t->tx->res->json->{cid}; 341 + 342 + $t->get_ok('/xrpc/com.atproto.sync.getLatestCommit?did=did:web:missing.test') 343 + ->status_is(400) 344 + ->json_is('/error' => 'RepoNotFound'); 332 345 333 346 $t->get_ok('/xrpc/com.atproto.server.getServiceAuth?aud=' . $service_did . '&lxm=com.atproto.repo.uploadBlob' => { 334 347 Authorization => "Bearer $access", ··· 371 384 ->json_has('/rev') 372 385 ->json_hasnt('/status'); 373 386 387 + $t->get_ok('/xrpc/com.atproto.sync.getRepoStatus?did=did:web:missing.test') 388 + ->status_is(400) 389 + ->json_is('/error' => 'RepoNotFound'); 390 + 374 391 $t->get_ok('/xrpc/com.atproto.sync.listRepos') 375 392 ->status_is(200) 376 393 ->json_is('/repos/0/did' => $did); ··· 388 405 $t->get_ok("/xrpc/com.atproto.sync.getHead?did=$did") 389 406 ->status_is(200) 390 407 ->json_like('/root' => qr/\Ab/); 408 + 409 + $t->get_ok('/xrpc/com.atproto.sync.getHead?did=did:web:missing.test') 410 + ->status_is(400) 411 + ->json_is('/error' => 'RepoNotFound'); 391 412 392 413 $t->post_ok('/xrpc/com.atproto.repo.deleteRecord' => { Authorization => "Bearer $access" } => json => { 393 414 repo => $did,