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.

Gate self-service invite creation behind config

alice a608d802 b2ee1a4b

+72 -8
+1
README.md
··· 33 33 - The deployment guide includes a reverse-proxy layout, a sample `systemd` unit, validation commands, and a `createAccount` example for bootstrapping the first user. 34 34 - If `service_handle_domain` is `example.com`, submitting `handle: "alice"` to `com.atproto.server.createAccount` creates `alice.example.com`. 35 35 - If `invite_code_required` is enabled, public signup is disabled until a valid invite code is supplied. 36 + - `com.atproto.server.createInviteCode` and `com.atproto.server.createInviteCodes` are admin-only by default. Set `self_service_invite_codes` to enable self-service invite minting for authenticated full-access sessions, limited to the caller's own account. 36 37 - `script/perlsky-admin create-invite` can mint invite codes locally on the server without needing an existing user session. 37 38 - The invite-only bootstrap flow is documented with copy-pasteable commands in `docs/DEPLOYMENT.md`. 38 39 - Browser clients such as `bsky.app` can talk to `perlsky` directly because XRPC and DID-document responses include CORS headers and answer OPTIONS preflight requests.
+44 -8
lib/ATProto/PDS/API/Server.pm
··· 8 8 use Exporter 'import'; 9 9 use JSON::PP (); 10 10 11 - use ATProto::PDS::API::Helpers qw(find_account invite_code_view verify_account_password verify_login_password); 11 + use ATProto::PDS::API::Helpers qw(find_account invite_code_view require_admin verify_account_password verify_login_password); 12 12 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 13 13 use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt encode_service_jwt); 14 14 use ATProto::PDS::Auth::Password qw(hash_password random_hex); ··· 566 566 }); 567 567 568 568 $registry->register('com.atproto.server.createInviteCode', sub ($c, $endpoint) { 569 - my (undef, $account) = require_auth($c, audience => 'access'); 570 569 my $body = $c->req->json || {}; 570 + my ($created_by, $target) = _invite_code_targets($c, $body); 571 571 my $code = _new_invite_code(); 572 572 $c->store->create_invite_code( 573 573 code => $code, 574 - for_account => $body->{forAccount} || $account->{did}, 575 - created_by => $account->{did}, 574 + for_account => $target, 575 + created_by => $created_by, 576 576 use_count => $body->{useCount} // 1, 577 577 ); 578 578 return { code => $code }; 579 579 }); 580 580 581 581 $registry->register('com.atproto.server.createInviteCodes', sub ($c, $endpoint) { 582 - my (undef, $account) = require_auth($c, audience => 'access'); 583 582 my $body = $c->req->json || {}; 584 - my @accounts = @{ $body->{forAccounts} || [ $account->{did} ] }; 583 + my ($created_by, $accounts) = _invite_code_targets($c, $body, multiple => 1); 585 584 my $count = $body->{codeCount} // 1; 586 585 my @result; 587 - for my $target (@accounts) { 586 + for my $target (@$accounts) { 588 587 my @codes; 589 588 for (1 .. $count) { 590 589 my $code = _new_invite_code(); 591 590 $c->store->create_invite_code( 592 591 code => $code, 593 592 for_account => $target, 594 - created_by => $account->{did}, 593 + created_by => $created_by, 595 594 use_count => $body->{useCount} // 1, 596 595 ); 597 596 push @codes, $code; ··· 746 745 return 1 if $lxm =~ /\Achat\.bsky\./; 747 746 return 1 if $lxm eq 'com.atproto.server.createaccount'; 748 747 return 0; 748 + } 749 + 750 + sub _invite_code_targets ($c, $body, %opts) { 751 + if (_uses_admin_authorization($c) || !$c->config_value('self_service_invite_codes', 0)) { 752 + require_admin($c); 753 + if ($opts{multiple}) { 754 + my @targets = @{ $body->{forAccounts} || ['admin'] }; 755 + @targets = ('admin') unless @targets; 756 + return ('admin', \@targets); 757 + } 758 + my $target = $body->{forAccount}; 759 + $target = 'admin' unless defined($target) && length($target); 760 + return ('admin', $target); 761 + } 762 + 763 + my (undef, $account) = require_auth($c, audience => 'access', required_scope => 'full'); 764 + if ($opts{multiple}) { 765 + my @targets = @{ $body->{forAccounts} || [ $account->{did} ] }; 766 + @targets = ($account->{did}) unless @targets; 767 + xrpc_error(400, 'InvalidRequest', 'Self-service invite creation can only target the authenticated account') 768 + if grep { !defined($_) || !length($_) || $_ ne $account->{did} } @targets; 769 + return ($account->{did}, \@targets); 770 + } 771 + 772 + my $target = $body->{forAccount}; 773 + $target = $account->{did} unless defined($target) && length($target); 774 + xrpc_error(400, 'InvalidRequest', 'Self-service invite creation can only target the authenticated account') 775 + unless $target eq $account->{did}; 776 + return ($account->{did}, $target); 777 + } 778 + 779 + sub _uses_admin_authorization ($c) { 780 + my $auth = $c->req->headers->authorization // q(); 781 + return 1 if $auth =~ /\ABasic\s+/i; 782 + return 0 unless $auth =~ /\ABearer\s+(\S+)\z/i; 783 + my $token = $1; 784 + return $token !~ /\A[^.]+\.[^.]+\.[^.]+\z/; 749 785 } 750 786 751 787 sub _require_action_token ($c, %args) {
+8
t/extended-api.t
··· 33 33 service_did_method => 'did:web', 34 34 jwt_secret => 'extended-secret', 35 35 admin_password => 'admin-secret', 36 + self_service_invite_codes => 1, 36 37 data_dir => $tmp, 37 38 db_path => File::Spec->catfile($tmp, 'perlsky.sqlite'), 38 39 }, ··· 58 59 ->json_has('/code'); 59 60 60 61 my $invite_code = $t->tx->res->json->{code}; 62 + 63 + $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 64 + Authorization => "Bearer $access", 65 + } => json => { 66 + forAccount => 'did:web:example.test:users:someone-else', 67 + })->status_is(400) 68 + ->json_is('/error' => 'InvalidRequest'); 61 69 62 70 $t->post_ok('/xrpc/com.atproto.repo.applyWrites' => { 63 71 Authorization => "Bearer $access",
+19
t/invite-gating.t
··· 5 5 use File::Spec; 6 6 use File::Temp qw(tempdir); 7 7 use FindBin qw($Bin); 8 + use MIME::Base64 qw(encode_base64); 8 9 use Test::More; 9 10 10 11 BEGIN { ··· 70 71 inviteCode => $code, 71 72 })->status_is(200) 72 73 ->json_is('/handle', 'alice.pds.example.test'); 74 + 75 + my $access = $t->tx->res->json->{accessJwt}; 76 + 77 + $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 78 + Authorization => "Bearer $access", 79 + } => json => { 80 + useCount => 1, 81 + })->status_is(403) 82 + ->json_is('/error', 'InvalidAdminToken'); 83 + 84 + my $admin_auth = 'Basic ' . encode_base64('admin:admin-secret', q()); 85 + 86 + $t->post_ok('/xrpc/com.atproto.server.createInviteCode' => { 87 + Authorization => $admin_auth, 88 + } => json => { 89 + useCount => 1, 90 + })->status_is(200) 91 + ->json_has('/code'); 73 92 74 93 done_testing;