Laravel AT Protocol Client (alpha & unstable)

OAuth Scopes#

The AT Protocol uses OAuth scopes to control what actions an application can perform on behalf of a user. AtpClient provides attributes for documenting scope requirements on endpoints.

Note: The #[ScopedEndpoint] and #[PublicEndpoint] attributes currently serve as documentation only. Runtime scope validation and enforcement will be implemented in a future release. Using these attributes correctly now ensures forward compatibility.

Quick Reference#

Scope Enum#

use SocialDept\AtpClient\Enums\Scope;

// Transition scopes (current AT Protocol scopes)
Scope::Atproto              // 'atproto' - Full access
Scope::TransitionGeneric    // 'transition:generic' - General API access
Scope::TransitionEmail      // 'transition:email' - Email access
Scope::TransitionChat       // 'transition:chat.bsky' - Chat access

// Granular scope builders (future AT Protocol scopes)
Scope::repo('app.bsky.feed.post', ['create', 'delete'])  // Record operations
Scope::rpc('app.bsky.feed.getTimeline')                  // RPC endpoint access
Scope::blob('image/*')                                    // Blob upload access
Scope::account('email')                                   // Account attribute access
Scope::identity('handle')                                 // Identity attribute access

ScopedEndpoint Attribute#

use SocialDept\AtpClient\Attributes\ScopedEndpoint;
use SocialDept\AtpClient\Enums\Scope;

#[ScopedEndpoint(Scope::TransitionGeneric)]
public function getTimeline(): GetTimelineResponse
{
    // Method implementation
}

Understanding AT Protocol Scopes#

Current Transition Scopes#

The AT Protocol is currently in a transition period where broad "transition scopes" are used:

Scope Description
atproto Full access to the AT Protocol
transition:generic General API access for most operations
transition:email Access to email-related operations
transition:chat.bsky Access to Bluesky chat features

Future Granular Scopes#

The AT Protocol is moving toward granular scopes that provide fine-grained access control:

// Record operations
'repo:app.bsky.feed.post'                      // All operations on posts
'repo:app.bsky.feed.post?action=create'        // Only create posts
'repo:app.bsky.feed.like?action=create&action=delete'  // Create or delete likes
'repo:*'                                       // All collections, all actions

// RPC endpoint access
'rpc:app.bsky.feed.getTimeline'               // Access to timeline endpoint
'rpc:app.bsky.feed.*'                         // All feed endpoints

// Blob operations
'blob:image/*'                                 // Upload images
'blob:*/*'                                     // Upload any blob type

// Account and identity
'account:email'                                // Access email
'identity:handle'                              // Manage handle

The ScopedEndpoint Attribute#

The #[ScopedEndpoint] attribute documents scope requirements on methods that require authentication.

Basic Usage#

<?php

namespace App\Atp;

use SocialDept\AtpClient\Attributes\ScopedEndpoint;
use SocialDept\AtpClient\Client\Requests\Request;
use SocialDept\AtpClient\Enums\Scope;

class CustomClient extends Request
{
    #[ScopedEndpoint(Scope::TransitionGeneric)]
    public function getTimeline(): array
    {
        return $this->atp->client->get('app.bsky.feed.getTimeline')->json();
    }
}

With Granular Scope#

Document the future granular scope that will replace the transition scope:

#[ScopedEndpoint(
    Scope::TransitionGeneric,
    granular: 'rpc:app.bsky.feed.getTimeline'
)]
public function getTimeline(): GetTimelineResponse
{
    // ...
}

With Description#

Add a human-readable description for documentation:

#[ScopedEndpoint(
    Scope::TransitionGeneric,
    granular: 'rpc:app.bsky.feed.getTimeline',
    description: 'Access to the user\'s home timeline'
)]
public function getTimeline(): GetTimelineResponse
{
    // ...
}

Multiple Scopes (AND Logic)#

When a method requires multiple scopes, all must be present:

#[ScopedEndpoint([Scope::TransitionGeneric, Scope::TransitionEmail])]
public function getEmailPreferences(): array
{
    // Requires BOTH scopes
}

Multiple Attributes (OR Logic)#

Use multiple attributes for alternative scope requirements:

#[ScopedEndpoint(Scope::Atproto)]
#[ScopedEndpoint(Scope::TransitionGeneric)]
public function getProfile(string $actor): ProfileViewDetailed
{
    // Either scope satisfies the requirement
}

Scope Enforcement (Planned)#

Coming Soon: Runtime scope enforcement is not yet implemented. The following documentation describes planned functionality for a future release.

Configuration#

Configure scope enforcement in config/client.php or via environment variables:

'scope_enforcement' => ScopeEnforcementLevel::Permissive,
Level Behavior
Strict Throws MissingScopeException if required scopes are missing
Permissive Logs a warning but attempts the request anyway

Set via environment variable:

ATP_SCOPE_ENFORCEMENT=strict

Programmatic Scope Checking#

Check scopes programmatically using the ScopeChecker:

use SocialDept\AtpClient\Auth\ScopeChecker;
use SocialDept\AtpClient\Facades\Atp;

$checker = app(ScopeChecker::class);
$session = Atp::as($did)->client->session();

// Check if session has a scope
if ($checker->hasScope($session, Scope::TransitionGeneric)) {
    // Session has the scope
}

// Check multiple scopes
if ($checker->check($session, [Scope::TransitionGeneric, Scope::TransitionEmail])) {
    // Session has ALL required scopes
}

// Check and fail if missing (respects enforcement level)
$checker->checkOrFail($session, [Scope::TransitionGeneric]);

// Check repo scope for specific action
if ($checker->checkRepoScope($session, 'app.bsky.feed.post', 'create')) {
    // Can create posts
}

Granular Pattern Matching#

The scope checker supports wildcard patterns:

// Check if session can access any feed endpoint
$checker->matchesGranular($session, 'rpc:app.bsky.feed.*');

// Check if session can upload images
$checker->matchesGranular($session, 'blob:image/*');

// Check if session has any repo access
$checker->matchesGranular($session, 'repo:*');

Route Middleware (Planned)#

Coming Soon: Route middleware is not yet implemented. The following documentation describes planned functionality for a future release.

Protect Laravel routes based on ATP session scopes:

use Illuminate\Support\Facades\Route;

// Single scope
Route::get('/timeline', TimelineController::class)
    ->middleware('atp.scope:transition:generic');

// Multiple scopes (AND logic)
Route::get('/email-settings', EmailSettingsController::class)
    ->middleware('atp.scope:transition:generic,transition:email');

Middleware Configuration#

Configure middleware behavior in config/client.php:

'scope_authorization' => [
    // What to do when scope check fails
    'failure_action' => ScopeAuthorizationFailure::Abort,  // abort, redirect, or exception

    // Where to redirect (when failure_action is 'redirect')
    'redirect_to' => '/login',
],
Failure Action Behavior
Abort Returns 403 Forbidden response
Redirect Redirects to configured URL
Exception Throws ScopeAuthorizationException

Set via environment variables:

ATP_SCOPE_FAILURE_ACTION=redirect
ATP_SCOPE_REDIRECT=/auth/login

User Model Integration#

For the middleware to work, your User model must implement HasAtpSession:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use SocialDept\AtpClient\Contracts\HasAtpSession;

class User extends Authenticatable implements HasAtpSession
{
    public function getAtpDid(): ?string
    {
        return $this->atp_did;
    }
}

Public Mode and Scopes#

Methods marked with #[PublicEndpoint] can be called without authentication using Atp::public():

// Public mode - no authentication required
$client = Atp::public('https://public.api.bsky.app');
$client->bsky->actor->getProfile('someone.bsky.social');  // Works without auth

// Authenticated mode - for endpoints requiring scopes
$client = Atp::as($did);
$client->bsky->feed->getTimeline();  // Requires transition:generic scope

Methods with #[PublicEndpoint] work in both modes, while methods with #[ScopedEndpoint] require authentication.

Exception Handling (Planned)#

Coming Soon: These exceptions will be thrown when scope enforcement is implemented in a future release.

MissingScopeException#

Will be thrown when required scopes are missing and enforcement is strict:

use SocialDept\AtpClient\Exceptions\MissingScopeException;

try {
    $timeline = $client->bsky->feed->getTimeline();
} catch (MissingScopeException $e) {
    $missing = $e->getMissingScopes();   // Scopes that are missing
    $granted = $e->getGrantedScopes();   // Scopes the session has

    // Handle missing scope
}

ScopeAuthorizationException#

Will be thrown by middleware when route access is denied:

use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;

try {
    // Route protected by atp.scope middleware
} catch (ScopeAuthorizationException $e) {
    $required = $e->getRequiredScopes();
    $granted = $e->getGrantedScopes();
    $message = $e->getMessage();
}

Best Practices#

1. Document All Scope Requirements#

Always add #[ScopedEndpoint] to methods that require authentication:

#[ScopedEndpoint(
    Scope::TransitionGeneric,
    granular: 'rpc:app.bsky.feed.getTimeline',
    description: 'Fetches the authenticated user\'s home timeline'
)]
public function getTimeline(): GetTimelineResponse

2. Use the Scope Enum#

Prefer the Scope enum over string literals for type safety:

// Good
#[ScopedEndpoint(Scope::TransitionGeneric)]

// Avoid
#[ScopedEndpoint('transition:generic')]

3. Request Minimal Scopes#

When implementing OAuth, request only the scopes your application needs:

$authUrl = Atp::oauth()->getAuthorizationUrl([
    'scope' => 'atproto transition:generic',
]);

4. Handle Missing Scopes Gracefully#

Check for scope availability before attempting operations:

$checker = app(ScopeChecker::class);
$session = $client->client->session();

if ($checker->hasScope($session, Scope::TransitionChat)) {
    $conversations = $client->chat->getConversations();
} else {
    // Inform user they need to re-authorize with chat scope
}

5. Use Permissive Mode in Development#

Start with permissive enforcement during development, then switch to strict for production:

# .env.local
ATP_SCOPE_ENFORCEMENT=permissive

# .env.production
ATP_SCOPE_ENFORCEMENT=strict