···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}
···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+}
···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+}
···23namespace SocialDept\AtpClient\RichText;
45-use SocialDept\AtpResolver\Facades\Resolver;
67class TextBuilder
8{
9- protected string $text = '';
10- protected array $facets = [];
1112 /**
13 * Create a new text builder instance
14 */
15 public static function make(): self
16 {
17- return new self();
18 }
1920 /**
···22 */
23 public static function build(callable $callback): array
24 {
25- $builder = new self();
26 $callback($builder);
2728 return $builder->toArray();
29 }
3031 /**
32- * Add plain text
33- */
34- public function text(string $text): self
35- {
36- $this->text .= $text;
37-38- return $this;
39- }
40-41- /**
42- * Add a new line
43- */
44- public function newLine(): self
45- {
46- $this->text .= "\n";
47-48- return $this;
49- }
50-51- /**
52- * Add mention (@handle)
53- */
54- public function mention(string $handle, ?string $did = null): self
55- {
56- $handle = ltrim($handle, '@');
57- $start = $this->getBytePosition();
58- $this->text .= '@'.$handle;
59- $end = $this->getBytePosition();
60-61- // Resolve DID if not provided
62- if (! $did) {
63- try {
64- $did = Resolver::handleToDid($handle);
65- } catch (\Exception $e) {
66- // If resolution fails, still add the text but skip the facet
67- return $this;
68- }
69- }
70-71- $this->facets[] = [
72- 'index' => [
73- 'byteStart' => $start,
74- 'byteEnd' => $end,
75- ],
76- 'features' => [
77- [
78- '$type' => 'app.bsky.richtext.facet#mention',
79- 'did' => $did,
80- ],
81- ],
82- ];
83-84- return $this;
85- }
86-87- /**
88- * Add link with custom display text
89- */
90- public function link(string $text, string $uri): self
91- {
92- $start = $this->getBytePosition();
93- $this->text .= $text;
94- $end = $this->getBytePosition();
95-96- $this->facets[] = [
97- 'index' => [
98- 'byteStart' => $start,
99- 'byteEnd' => $end,
100- ],
101- 'features' => [
102- [
103- '$type' => 'app.bsky.richtext.facet#link',
104- 'uri' => $uri,
105- ],
106- ],
107- ];
108-109- return $this;
110- }
111-112- /**
113- * Add a URL (displayed as-is)
114- */
115- public function url(string $url): self
116- {
117- return $this->link($url, $url);
118- }
119-120- /**
121- * Add hashtag
122- */
123- public function tag(string $tag): self
124- {
125- $tag = ltrim($tag, '#');
126-127- $start = $this->getBytePosition();
128- $this->text .= '#'.$tag;
129- $end = $this->getBytePosition();
130-131- $this->facets[] = [
132- 'index' => [
133- 'byteStart' => $start,
134- 'byteEnd' => $end,
135- ],
136- 'features' => [
137- [
138- '$type' => 'app.bsky.richtext.facet#tag',
139- 'tag' => $tag,
140- ],
141- ],
142- ];
143-144- return $this;
145- }
146-147- /**
148- * Auto-detect and add facets from plain text
149- */
150- public function autoDetect(string $text): self
151- {
152- $start = $this->getBytePosition();
153- $this->text .= $text;
154-155- // Detect facets in the added text
156- $detected = FacetDetector::detect($text);
157-158- // Adjust byte positions to account for existing text
159- foreach ($detected as $facet) {
160- $facet['index']['byteStart'] += $start;
161- $facet['index']['byteEnd'] += $start;
162- $this->facets[] = $facet;
163- }
164-165- return $this;
166- }
167-168- /**
169- * Get current byte position
170- */
171- protected function getBytePosition(): int
172- {
173- return strlen($this->text);
174- }
175-176- /**
177- * Get the text content
178- */
179- public function getText(): string
180- {
181- return $this->text;
182- }
183-184- /**
185- * Get the facets
186- */
187- public function getFacets(): array
188- {
189- return $this->facets;
190- }
191-192- /**
193 * Build the final text and facets array
194 */
195 public function toArray(): array
196 {
197- return [
198- 'text' => $this->text,
199- 'facets' => $this->facets,
200- ];
201 }
202203 /**
···233 public function getByteCount(): int
234 {
235 return strlen($this->text);
236- }
237-238- /**
239- * Check if text exceeds AT Protocol post limit (300 graphemes)
240- */
241- public function exceedsLimit(int $limit = 300): bool
242- {
243- return $this->getGraphemeCount() > $limit;
244- }
245-246- /**
247- * Get grapheme count (closest to what AT Protocol uses)
248- */
249- public function getGraphemeCount(): int
250- {
251- return grapheme_strlen($this->text);
252 }
253254 /**
···23namespace SocialDept\AtpClient\RichText;
45+use SocialDept\AtpClient\Builders\Concerns\BuildsRichText;
67class TextBuilder
8{
9+ use BuildsRichText;
01011 /**
12 * Create a new text builder instance
13 */
14 public static function make(): self
15 {
16+ return new self;
17 }
1819 /**
···21 */
22 public static function build(callable $callback): array
23 {
24+ $builder = new self;
25 $callback($builder);
2627 return $builder->toArray();
28 }
2930 /**
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031 * Build the final text and facets array
32 */
33 public function toArray(): array
34 {
35+ return $this->getTextAndFacets();
00036 }
3738 /**
···68 public function getByteCount(): int
69 {
70 return strlen($this->text);
000000000000000071 }
7273 /**
+72
src/Session/Session.php
···45use SocialDept\AtpClient\Data\Credentials;
6use SocialDept\AtpClient\Data\DPoPKey;
0078class Session
9{
···51 public function expiresIn(): int
52 {
53 return $this->credentials->expiresIn();
000000000000000000000000000000000000000000000000000000000000000000000054 }
5556 public function withCredentials(Credentials $credentials): self
···45use SocialDept\AtpClient\Data\Credentials;
6use SocialDept\AtpClient\Data\DPoPKey;
7+use SocialDept\AtpClient\Enums\AuthType;
8+use SocialDept\AtpClient\Enums\Scope;
910class Session
11{
···53 public function expiresIn(): int
54 {
55 return $this->credentials->expiresIn();
56+ }
57+58+ public function scopes(): array
59+ {
60+ return $this->credentials->scope;
61+ }
62+63+ public function hasScope(string $scope): bool
64+ {
65+ return in_array($scope, $this->credentials->scope, true);
66+ }
67+68+ /**
69+ * Check if the session has the given scope (alias for hasScope with Scope enum support).
70+ */
71+ public function can(string|Scope $scope): bool
72+ {
73+ $scopeValue = $scope instanceof Scope ? $scope->value : $scope;
74+75+ return $this->hasScope($scopeValue);
76+ }
77+78+ /**
79+ * Check if the session has any of the given scopes.
80+ *
81+ * @param array<string|Scope> $scopes
82+ */
83+ public function canAny(array $scopes): bool
84+ {
85+ foreach ($scopes as $scope) {
86+ if ($this->can($scope)) {
87+ return true;
88+ }
89+ }
90+91+ return false;
92+ }
93+94+ /**
95+ * Check if the session has all of the given scopes.
96+ *
97+ * @param array<string|Scope> $scopes
98+ */
99+ public function canAll(array $scopes): bool
100+ {
101+ foreach ($scopes as $scope) {
102+ if (! $this->can($scope)) {
103+ return false;
104+ }
105+ }
106+107+ return true;
108+ }
109+110+ /**
111+ * Check if the session does NOT have the given scope.
112+ */
113+ public function cannot(string|Scope $scope): bool
114+ {
115+ return ! $this->can($scope);
116+ }
117+118+ public function authType(): AuthType
119+ {
120+ return $this->credentials->authType;
121+ }
122+123+ public function isLegacy(): bool
124+ {
125+ return $this->credentials->authType === AuthType::Legacy;
126 }
127128 public function withCredentials(Credentials $credentials): self
+26-21
src/Session/SessionManager.php
···8use SocialDept\AtpClient\Contracts\CredentialProvider;
9use SocialDept\AtpClient\Contracts\KeyStore;
10use SocialDept\AtpClient\Data\AccessToken;
11-use SocialDept\AtpClient\Events\TokenRefreshed;
12-use SocialDept\AtpClient\Events\TokenRefreshing;
013use SocialDept\AtpClient\Exceptions\AuthenticationException;
14use SocialDept\AtpClient\Exceptions\HandleResolutionException;
15use SocialDept\AtpClient\Exceptions\SessionExpiredException;
16use SocialDept\AtpResolver\Facades\Resolver;
01718class SessionManager
19{
···28 ) {}
2930 /**
31- * Resolve a handle or DID to a DID.
32 *
33 * @throws HandleResolutionException
34 */
35- protected function resolveToDid(string $handleOrDid): string
36 {
37 // If already a DID, return as-is
38- if (str_starts_with($handleOrDid, 'did:')) {
39- return $handleOrDid;
40 }
4142 // Resolve handle to DID
43- $did = Resolver::handleToDid($handleOrDid);
4445 if (! $did) {
46- throw new HandleResolutionException($handleOrDid);
47 }
4849 return $did;
50 }
5152 /**
53- * Get or create session for handle or DID
54 */
55- public function session(string $handleOrDid): Session
56 {
57- $did = $this->resolveToDid($handleOrDid);
5859 if (! isset($this->sessions[$did])) {
60 $this->sessions[$did] = $this->createSession($did);
···64 }
6566 /**
67- * Ensure session is valid, refresh if needed
68 */
69- public function ensureValid(string $handleOrDid): Session
70 {
71- $session = $this->session($handleOrDid);
7273 // Check if token needs refresh
74 if ($session->expiresIn() < $this->refreshThreshold) {
···79 }
8081 /**
82- * Create session from app password
83 */
84 public function fromAppPassword(
85- string $handleOrDid,
86 string $password
87 ): Session {
88- $did = $this->resolveToDid($handleOrDid);
89 $pdsEndpoint = Resolver::resolvePds($did);
9091 $response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [
92- 'identifier' => $handleOrDid,
93 'password' => $password,
94 ]);
95···97 throw new AuthenticationException('Login failed');
98 }
99100- $token = AccessToken::fromResponse($response->json(), $handleOrDid, $pdsEndpoint);
101102 // Store credentials using DID as key
103 $this->credentials->storeCredentials($did, $token);
00104105 return $this->createSession($did);
106 }
···138 $did = $session->did();
139140 // Fire event before refresh (allows developers to invalidate old token)
141- event(new TokenRefreshing($did, $session->refreshToken()));
142143 $newToken = $this->refresher->refresh(
144 refreshToken: $session->refreshToken(),
145 pdsEndpoint: $session->pdsEndpoint(),
146 dpopKey: $session->dpopKey(),
147 handle: $session->handle(),
0148 );
149150 // Update credentials (CRITICAL: refresh tokens are single-use)
151 $this->credentials->updateCredentials($did, $newToken);
152153 // Fire event after successful refresh
154- event(new TokenRefreshed($did, $newToken));
155156 // Update session
157 $newCreds = $this->credentials->getCredentials($did);
···8use SocialDept\AtpClient\Contracts\CredentialProvider;
9use SocialDept\AtpClient\Contracts\KeyStore;
10use SocialDept\AtpClient\Data\AccessToken;
11+use SocialDept\AtpClient\Events\SessionAuthenticated;
12+use SocialDept\AtpClient\Events\SessionRefreshing;
13+use SocialDept\AtpClient\Events\SessionUpdated;
14use SocialDept\AtpClient\Exceptions\AuthenticationException;
15use SocialDept\AtpClient\Exceptions\HandleResolutionException;
16use SocialDept\AtpClient\Exceptions\SessionExpiredException;
17use SocialDept\AtpResolver\Facades\Resolver;
18+use SocialDept\AtpResolver\Support\Identity;
1920class SessionManager
21{
···30 ) {}
3132 /**
33+ * Resolve an actor (handle or DID) to a DID.
34 *
35 * @throws HandleResolutionException
36 */
37+ protected function resolveToDid(string $actor): string
38 {
39 // If already a DID, return as-is
40+ if (Identity::isDid($actor)) {
41+ return $actor;
42 }
4344 // Resolve handle to DID
45+ $did = Resolver::handleToDid($actor);
4647 if (! $did) {
48+ throw new HandleResolutionException($actor);
49 }
5051 return $did;
52 }
5354 /**
55+ * Get or create session for an actor.
56 */
57+ public function session(string $actor): Session
58 {
59+ $did = $this->resolveToDid($actor);
6061 if (! isset($this->sessions[$did])) {
62 $this->sessions[$did] = $this->createSession($did);
···66 }
6768 /**
69+ * Ensure session is valid, refresh if needed.
70 */
71+ public function ensureValid(string $actor): Session
72 {
73+ $session = $this->session($actor);
7475 // Check if token needs refresh
76 if ($session->expiresIn() < $this->refreshThreshold) {
···81 }
8283 /**
84+ * Create session from app password.
85 */
86 public function fromAppPassword(
87+ string $actor,
88 string $password
89 ): Session {
90+ $did = $this->resolveToDid($actor);
91 $pdsEndpoint = Resolver::resolvePds($did);
9293 $response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [
94+ 'identifier' => $actor,
95 'password' => $password,
96 ]);
97···99 throw new AuthenticationException('Login failed');
100 }
101102+ $token = AccessToken::fromResponse($response->json(), $actor, $pdsEndpoint);
103104 // Store credentials using DID as key
105 $this->credentials->storeCredentials($did, $token);
106+107+ event(new SessionAuthenticated($token));
108109 return $this->createSession($did);
110 }
···142 $did = $session->did();
143144 // Fire event before refresh (allows developers to invalidate old token)
145+ event(new SessionRefreshing($session));
146147 $newToken = $this->refresher->refresh(
148 refreshToken: $session->refreshToken(),
149 pdsEndpoint: $session->pdsEndpoint(),
150 dpopKey: $session->dpopKey(),
151 handle: $session->handle(),
152+ authType: $session->authType(),
153 );
154155 // Update credentials (CRITICAL: refresh tokens are single-use)
156 $this->credentials->updateCredentials($did, $newToken);
157158 // Fire event after successful refresh
159+ event(new SessionUpdated($session, $newToken));
160161 // Update session
162 $newCreds = $this->credentials->getCredentials($did);