···1+<?php
2+3+namespace SocialDept\AtpClient\Attributes;
4+5+use Attribute;
6+7+/**
8+ * Documents that a method is a public endpoint that does not require authentication.
9+ *
10+ * This attribute currently serves as documentation to indicate which AT Protocol
11+ * endpoints can be called without an authenticated session. It helps developers
12+ * understand which endpoints work with `Atp::public()` against public API endpoints
13+ * like `https://public.api.bsky.app`.
14+ *
15+ * While this attribute does not currently perform runtime enforcement, scope
16+ * validation will be implemented in a future release. Correctly attributing
17+ * endpoints now ensures forward compatibility when enforcement is enabled.
18+ *
19+ * Public endpoints typically include operations like:
20+ * - Reading public profiles and posts
21+ * - Searching actors and content
22+ * - Resolving handles to DIDs
23+ * - Accessing repository data (sync endpoints)
24+ * - Describing servers and feed generators
25+ *
26+ * @example Basic usage
27+ * ```php
28+ * #[PublicEndpoint]
29+ * public function getProfile(string $actor): ProfileViewDetailed
30+ * ```
31+ *
32+ * @see \SocialDept\AtpClient\Attributes\ScopedEndpoint For endpoints that require authentication
33+ */
34+#[Attribute(Attribute::TARGET_METHOD)]
35+class PublicEndpoint
36+{
37+ /**
38+ * @param string $description Human-readable description of the endpoint
39+ */
40+ public function __construct(
41+ public readonly string $description = '',
42+ ) {}
43+}
···9 /**
10 * The parent AtpClient instance we belong to
11 */
12- public AtpClient $atp;
1314 public function __construct($parent)
15 {
16- $this->atp = $parent->atp;
17 }
18}
···9 /**
10 * The parent AtpClient instance we belong to
11 */
12+ protected AtpClient $atp;
1314 public function __construct($parent)
15 {
16+ $this->atp = $parent->root();
17 }
18}
···8interface CredentialProvider
9{
10 /**
11- * Get credentials for the given identifier
12 */
13- public function getCredentials(string $identifier): ?Credentials;
1415 /**
16 * Store new credentials (initial OAuth or app password login)
17 */
18- public function storeCredentials(string $identifier, AccessToken $token): void;
1920 /**
21 * Update credentials after token refresh
22 * CRITICAL: Refresh tokens are single-use!
23 */
24- public function updateCredentials(string $identifier, AccessToken $token): void;
2526 /**
27 * Remove credentials
28 */
29- public function removeCredentials(string $identifier): void;
30}
···8interface CredentialProvider
9{
10 /**
11+ * Get credentials for the given DID
12 */
13+ public function getCredentials(string $did): ?Credentials;
1415 /**
16 * Store new credentials (initial OAuth or app password login)
17 */
18+ public function storeCredentials(string $did, AccessToken $token): void;
1920 /**
21 * Update credentials after token refresh
22 * CRITICAL: Refresh tokens are single-use!
23 */
24+ public function updateCredentials(string $did, AccessToken $token): void;
2526 /**
27 * Remove credentials
28 */
29+ public function removeCredentials(string $did): void;
30}
+11
src/Contracts/HasAtpSession.php
···00000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Contracts;
4+5+interface HasAtpSession
6+{
7+ /**
8+ * Get the ATP DID associated with this model.
9+ */
10+ public function getAtpDid(): ?string;
11+}
+56-3
src/Data/AccessToken.php
···23namespace SocialDept\AtpClient\Data;
40005class AccessToken
6{
7 public function __construct(
···10 public readonly string $did,
11 public readonly \DateTimeInterface $expiresAt,
12 public readonly ?string $handle = null,
00013 ) {}
1415- public static function fromResponse(array $data): self
00000016 {
00000000000000000017 return new self(
18 accessJwt: $data['accessJwt'],
19 refreshJwt: $data['refreshJwt'],
20 did: $data['did'],
21- expiresAt: now()->addSeconds($data['expiresIn'] ?? 300),
22- handle: $data['handle'] ?? null,
00023 );
0000000000000000000024 }
25}
···23namespace SocialDept\AtpClient\Data;
45+use Carbon\Carbon;
6+use SocialDept\AtpClient\Enums\AuthType;
7+8class AccessToken
9{
10 public function __construct(
···13 public readonly string $did,
14 public readonly \DateTimeInterface $expiresAt,
15 public readonly ?string $handle = null,
16+ public readonly ?string $issuer = null,
17+ public readonly array $scope = [],
18+ public readonly AuthType $authType = AuthType::OAuth,
19 ) {}
2021+ /**
22+ * Create from API response.
23+ *
24+ * Handles both legacy createSession format (accessJwt, refreshJwt, did)
25+ * and OAuth token format (access_token, refresh_token, sub).
26+ */
27+ public static function fromResponse(array $data, ?string $handle = null, ?string $issuer = null): self
28 {
29+ // OAuth token endpoint format
30+ if (isset($data['access_token'])) {
31+ return new self(
32+ accessJwt: $data['access_token'],
33+ refreshJwt: $data['refresh_token'] ?? '',
34+ did: $data['sub'] ?? '',
35+ expiresAt: now()->addSeconds($data['expires_in'] ?? 300),
36+ handle: $handle,
37+ issuer: $issuer,
38+ scope: isset($data['scope']) ? explode(' ', $data['scope']) : [],
39+ authType: AuthType::OAuth,
40+ );
41+ }
42+43+ // Legacy createSession format (app passwords have full access)
44+ // Parse expiry from JWT since createSession doesn't return expiresIn
45+ $expiresAt = self::parseJwtExpiry($data['accessJwt']) ?? now()->addHour();
46+47 return new self(
48 accessJwt: $data['accessJwt'],
49 refreshJwt: $data['refreshJwt'],
50 did: $data['did'],
51+ expiresAt: $expiresAt,
52+ handle: $data['handle'] ?? $handle,
53+ issuer: $issuer,
54+ scope: ['atproto', 'transition:generic', 'transition:email'],
55+ authType: AuthType::Legacy,
56 );
57+ }
58+59+ /**
60+ * Parse the expiry timestamp from a JWT's payload.
61+ */
62+ protected static function parseJwtExpiry(string $jwt): ?\DateTimeInterface
63+ {
64+ $parts = explode('.', $jwt);
65+66+ if (count($parts) !== 3) {
67+ return null;
68+ }
69+70+ $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
71+72+ if (! isset($payload['exp'])) {
73+ return null;
74+ }
75+76+ return Carbon::createFromTimestamp($payload['exp']);
77 }
78}
+2
src/Data/AuthorizationRequest.php
···10 public readonly string $codeVerifier,
11 public readonly DPoPKey $dpopKey,
12 public readonly string $requestUri,
0013 ) {}
14}
···10 public readonly string $codeVerifier,
11 public readonly DPoPKey $dpopKey,
12 public readonly string $requestUri,
13+ public readonly string $pdsEndpoint,
14+ public readonly ?string $handle = null,
15 ) {}
16}
+6-1
src/Data/Credentials.php
···23namespace SocialDept\AtpClient\Data;
4005class Credentials
6{
7 public function __construct(
8- public readonly string $identifier,
9 public readonly string $did,
10 public readonly string $accessToken,
11 public readonly string $refreshToken,
12 public readonly \DateTimeInterface $expiresAt,
000013 ) {}
1415 public function isExpired(): bool
···23namespace SocialDept\AtpClient\Data;
45+use SocialDept\AtpClient\Enums\AuthType;
6+7class Credentials
8{
9 public function __construct(
010 public readonly string $did,
11 public readonly string $accessToken,
12 public readonly string $refreshToken,
13 public readonly \DateTimeInterface $expiresAt,
14+ public readonly ?string $handle = null,
15+ public readonly ?string $issuer = null,
16+ public readonly array $scope = [],
17+ public readonly AuthType $authType = AuthType::OAuth,
18 ) {}
1920 public function isExpired(): bool
+30-6
src/Data/DPoPKey.php
···45use phpseclib3\Crypt\Common\PrivateKey;
6use phpseclib3\Crypt\Common\PublicKey;
078class DPoPKey
9{
000010 public function __construct(
11- public readonly PrivateKey $privateKey,
12- public readonly PublicKey $publicKey,
13 public readonly string $keyId,
14- ) {}
00000000000000000001516 public function getPublicJwk(): array
17 {
18- $jwks = json_decode($this->publicKey->toString('JWK'), true);
1920 // phpseclib returns JWKS format {"keys":[...]}, extract the first key
21 $jwk = $jwks['keys'][0] ?? $jwks;
···3233 public function getPrivateJwk(): array
34 {
35- $jwks = json_decode($this->privateKey->toString('JWK'), true);
3637 // phpseclib returns JWKS format {"keys":[...]}, extract the first key
38 $jwk = $jwks['keys'][0] ?? $jwks;
···4950 public function toPEM(): string
51 {
52- return $this->privateKey->toString('PKCS8');
53 }
54}
···45use phpseclib3\Crypt\Common\PrivateKey;
6use phpseclib3\Crypt\Common\PublicKey;
7+use phpseclib3\Crypt\PublicKeyLoader;
89class DPoPKey
10{
11+ protected string $privateKeyPem;
12+13+ protected string $publicKeyPem;
14+15 public function __construct(
16+ PrivateKey|string $privateKey,
17+ PublicKey|string $publicKey,
18 public readonly string $keyId,
19+ ) {
20+ // Store as PEM strings for serialization
21+ $this->privateKeyPem = $privateKey instanceof PrivateKey
22+ ? $privateKey->toString('PKCS8')
23+ : $privateKey;
24+25+ $this->publicKeyPem = $publicKey instanceof PublicKey
26+ ? $publicKey->toString('PKCS8')
27+ : $publicKey;
28+ }
29+30+ public function getPrivateKey(): PrivateKey
31+ {
32+ return PublicKeyLoader::load($this->privateKeyPem);
33+ }
34+35+ public function getPublicKey(): PublicKey
36+ {
37+ return PublicKeyLoader::load($this->publicKeyPem);
38+ }
3940 public function getPublicJwk(): array
41 {
42+ $jwks = json_decode($this->getPublicKey()->toString('JWK'), true);
4344 // phpseclib returns JWKS format {"keys":[...]}, extract the first key
45 $jwk = $jwks['keys'][0] ?? $jwks;
···5657 public function getPrivateJwk(): array
58 {
59+ $jwks = json_decode($this->getPrivateKey()->toString('JWK'), true);
6061 // phpseclib returns JWKS format {"keys":[...]}, extract the first key
62 $jwk = $jwks['keys'][0] ?? $jwks;
···7374 public function toPEM(): string
75 {
76+ return $this->privateKeyPem;
77 }
78}
···23namespace SocialDept\AtpClient\Data;
45-class StrongRef
000006{
7 public function __construct(
8 public readonly string $uri,
···23namespace SocialDept\AtpClient\Data;
45+use Illuminate\Contracts\Support\Arrayable;
6+7+/**
8+ * @implements Arrayable<string, string>
9+ */
10+class StrongRef implements Arrayable
11{
12 public function __construct(
13 public readonly string $uri,
+9
src/Enums/AuthType.php
···000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums;
4+5+enum AuthType: string
6+{
7+ case OAuth = 'oauth';
8+ case Legacy = 'legacy';
9+}
+12
src/Enums/Nsid/AtprotoIdentity.php
···000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum AtprotoIdentity: string
8+{
9+ use HasScopeHelpers;
10+ case ResolveHandle = 'com.atproto.identity.resolveHandle';
11+ case UpdateHandle = 'com.atproto.identity.updateHandle';
12+}
+17
src/Enums/Nsid/AtprotoRepo.php
···00000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum AtprotoRepo: string
8+{
9+ use HasScopeHelpers;
10+ case CreateRecord = 'com.atproto.repo.createRecord';
11+ case DeleteRecord = 'com.atproto.repo.deleteRecord';
12+ case PutRecord = 'com.atproto.repo.putRecord';
13+ case GetRecord = 'com.atproto.repo.getRecord';
14+ case ListRecords = 'com.atproto.repo.listRecords';
15+ case UploadBlob = 'com.atproto.repo.uploadBlob';
16+ case DescribeRepo = 'com.atproto.repo.describeRepo';
17+}
+14
src/Enums/Nsid/AtprotoServer.php
···00000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum AtprotoServer: string
8+{
9+ use HasScopeHelpers;
10+ case CreateSession = 'com.atproto.server.createSession';
11+ case RefreshSession = 'com.atproto.server.refreshSession';
12+ case GetSession = 'com.atproto.server.getSession';
13+ case DescribeServer = 'com.atproto.server.describeServer';
14+}
+19
src/Enums/Nsid/AtprotoSync.php
···0000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum AtprotoSync: string
8+{
9+ use HasScopeHelpers;
10+ case GetBlob = 'com.atproto.sync.getBlob';
11+ case GetRepo = 'com.atproto.sync.getRepo';
12+ case ListRepos = 'com.atproto.sync.listRepos';
13+ case ListReposByCollection = 'com.atproto.sync.listReposByCollection';
14+ case GetLatestCommit = 'com.atproto.sync.getLatestCommit';
15+ case GetRecord = 'com.atproto.sync.getRecord';
16+ case ListBlobs = 'com.atproto.sync.listBlobs';
17+ case GetBlocks = 'com.atproto.sync.getBlocks';
18+ case GetRepoStatus = 'com.atproto.sync.getRepoStatus';
19+}
+18
src/Enums/Nsid/BskyActor.php
···000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum BskyActor: string
8+{
9+ use HasScopeHelpers;
10+ case GetProfile = 'app.bsky.actor.getProfile';
11+ case GetProfiles = 'app.bsky.actor.getProfiles';
12+ case GetSuggestions = 'app.bsky.actor.getSuggestions';
13+ case SearchActors = 'app.bsky.actor.searchActors';
14+ case SearchActorsTypeahead = 'app.bsky.actor.searchActorsTypeahead';
15+16+ // Record type
17+ case Profile = 'app.bsky.actor.profile';
18+}
+29
src/Enums/Nsid/BskyFeed.php
···00000000000000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum BskyFeed: string
8+{
9+ use HasScopeHelpers;
10+ case DescribeFeedGenerator = 'app.bsky.feed.describeFeedGenerator';
11+ case GetAuthorFeed = 'app.bsky.feed.getAuthorFeed';
12+ case GetActorFeeds = 'app.bsky.feed.getActorFeeds';
13+ case GetActorLikes = 'app.bsky.feed.getActorLikes';
14+ case GetFeed = 'app.bsky.feed.getFeed';
15+ case GetFeedGenerator = 'app.bsky.feed.getFeedGenerator';
16+ case GetFeedGenerators = 'app.bsky.feed.getFeedGenerators';
17+ case GetLikes = 'app.bsky.feed.getLikes';
18+ case GetPostThread = 'app.bsky.feed.getPostThread';
19+ case GetPosts = 'app.bsky.feed.getPosts';
20+ case GetQuotes = 'app.bsky.feed.getQuotes';
21+ case GetRepostedBy = 'app.bsky.feed.getRepostedBy';
22+ case GetSuggestedFeeds = 'app.bsky.feed.getSuggestedFeeds';
23+ case GetTimeline = 'app.bsky.feed.getTimeline';
24+ case SearchPosts = 'app.bsky.feed.searchPosts';
25+26+ // Record types
27+ case Post = 'app.bsky.feed.post';
28+ case Like = 'app.bsky.feed.like';
29+}
+22
src/Enums/Nsid/BskyGraph.php
···0000000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum BskyGraph: string
8+{
9+ use HasScopeHelpers;
10+ case GetFollowers = 'app.bsky.graph.getFollowers';
11+ case GetFollows = 'app.bsky.graph.getFollows';
12+ case GetKnownFollowers = 'app.bsky.graph.getKnownFollowers';
13+ case GetList = 'app.bsky.graph.getList';
14+ case GetLists = 'app.bsky.graph.getLists';
15+ case GetRelationships = 'app.bsky.graph.getRelationships';
16+ case GetStarterPack = 'app.bsky.graph.getStarterPack';
17+ case GetStarterPacks = 'app.bsky.graph.getStarterPacks';
18+ case GetSuggestedFollowsByActor = 'app.bsky.graph.getSuggestedFollowsByActor';
19+20+ // Record type
21+ case Follow = 'app.bsky.graph.follow';
22+}
+11
src/Enums/Nsid/BskyLabeler.php
···00000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum BskyLabeler: string
8+{
9+ use HasScopeHelpers;
10+ case GetServices = 'app.bsky.labeler.getServices';
11+}
+13
src/Enums/Nsid/ChatActor.php
···0000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum ChatActor: string
8+{
9+ use HasScopeHelpers;
10+ case GetActorMetadata = 'chat.bsky.actor.getActorMetadata';
11+ case ExportAccountData = 'chat.bsky.actor.exportAccountData';
12+ case DeleteAccount = 'chat.bsky.actor.deleteAccount';
13+}
+22
src/Enums/Nsid/ChatConvo.php
···0000000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum ChatConvo: string
8+{
9+ use HasScopeHelpers;
10+ case GetConvo = 'chat.bsky.convo.getConvo';
11+ case GetConvoForMembers = 'chat.bsky.convo.getConvoForMembers';
12+ case ListConvos = 'chat.bsky.convo.listConvos';
13+ case GetMessages = 'chat.bsky.convo.getMessages';
14+ case SendMessage = 'chat.bsky.convo.sendMessage';
15+ case SendMessageBatch = 'chat.bsky.convo.sendMessageBatch';
16+ case DeleteMessageForSelf = 'chat.bsky.convo.deleteMessageForSelf';
17+ case UpdateRead = 'chat.bsky.convo.updateRead';
18+ case MuteConvo = 'chat.bsky.convo.muteConvo';
19+ case UnmuteConvo = 'chat.bsky.convo.unmuteConvo';
20+ case LeaveConvo = 'chat.bsky.convo.leaveConvo';
21+ case GetLog = 'chat.bsky.convo.getLog';
22+}
+34
src/Enums/Nsid/Concerns/HasScopeHelpers.php
···0000000000000000000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid\Concerns;
4+5+trait HasScopeHelpers
6+{
7+ /**
8+ * Get the RPC scope format for this NSID.
9+ *
10+ * @example BskyActor::GetProfile->rpc() // "rpc:app.bsky.actor.getProfile"
11+ */
12+ public function rpc(): string
13+ {
14+ return 'rpc:' . $this->value;
15+ }
16+17+ /**
18+ * Get the repo scope format for this NSID.
19+ *
20+ * @example BskyGraph::Follow->repo(['create']) // "repo:app.bsky.graph.follow?action=create"
21+ * @example BskyFeed::Post->repo(['create', 'delete']) // "repo:app.bsky.feed.post?action=create&action=delete"
22+ * @example BskyFeed::Post->repo() // "repo:app.bsky.feed.post"
23+ */
24+ public function repo(array $actions = []): string
25+ {
26+ $scope = 'repo:' . $this->value;
27+28+ if (! empty($actions)) {
29+ $scope .= '?' . implode('&', array_map(fn ($action) => "action={$action}", $actions));
30+ }
31+32+ return $scope;
33+ }
34+}
+18
src/Enums/Nsid/OzoneModeration.php
···000000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum OzoneModeration: string
8+{
9+ use HasScopeHelpers;
10+ case GetEvent = 'tools.ozone.moderation.getEvent';
11+ case GetEvents = 'tools.ozone.moderation.getEvents';
12+ case GetRecord = 'tools.ozone.moderation.getRecord';
13+ case GetRepo = 'tools.ozone.moderation.getRepo';
14+ case QueryEvents = 'tools.ozone.moderation.queryEvents';
15+ case QueryStatuses = 'tools.ozone.moderation.queryStatuses';
16+ case SearchRepos = 'tools.ozone.moderation.searchRepos';
17+ case EmitEvent = 'tools.ozone.moderation.emitEvent';
18+}
+12
src/Enums/Nsid/OzoneServer.php
···000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum OzoneServer: string
8+{
9+ use HasScopeHelpers;
10+ case GetBlob = 'tools.ozone.server.getBlob';
11+ case GetConfig = 'tools.ozone.server.getConfig';
12+}
+15
src/Enums/Nsid/OzoneTeam.php
···000000000000000
···1+<?php
2+3+namespace SocialDept\AtpClient\Enums\Nsid;
4+5+use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6+7+enum OzoneTeam: string
8+{
9+ use HasScopeHelpers;
10+ case GetMember = 'tools.ozone.team.getMember';
11+ case ListMembers = 'tools.ozone.team.listMembers';
12+ case AddMember = 'tools.ozone.team.addMember';
13+ case UpdateMember = 'tools.ozone.team.updateMember';
14+ case DeleteMember = 'tools.ozone.team.deleteMember';
15+}