@kingironman2011/better-auth-bsky 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,831 @@
1
+ import {
2
+ OAuthClient,
3
+ buildPublicClientMetadata,
4
+ OAuthCallbackError,
5
+ } from "@atcute/oauth-node-client";
6
+ import {
7
+ LocalActorResolver,
8
+ CompositeDidDocumentResolver,
9
+ PlcDidDocumentResolver,
10
+ WebDidDocumentResolver,
11
+ CompositeHandleResolver,
12
+ DohJsonHandleResolver,
13
+ WellKnownHandleResolver,
14
+ } from "@atcute/identity-resolver";
15
+ import { Client } from "@atcute/client";
16
+ import type { BetterAuthPlugin } from "better-auth";
17
+ import { createAuthEndpoint, APIError } from "better-auth/api";
18
+ import { setSessionCookie } from "better-auth/cookies";
19
+ import * as v from "valibot";
20
+
21
+ import { isDid } from "@atcute/lexicons/syntax";
22
+ import type { Did } from "@atcute/lexicons";
23
+
24
+ import type { AtprotoPluginOptions, AtprotoProfile } from "./types.js";
25
+ import { atprotoSchema } from "./types.js";
26
+ import { DbSessionStore, DbStateStore } from "./stores.js";
27
+
28
+ // ────────────────────────── Helpers ──────────────────────────
29
+
30
+ /** Safely extract a string field from an unknown record. */
31
+ function getString(
32
+ data: Record<string, unknown>,
33
+ key: string,
34
+ ): string | undefined {
35
+ const val = data[key];
36
+ return typeof val === "string" ? val : undefined;
37
+ }
38
+
39
+ /** Parse a JSON response body into a plain record. */
40
+ async function parseJsonResponse(
41
+ resp: Response,
42
+ ): Promise<Record<string, unknown>> {
43
+ const body: unknown = await resp.json();
44
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
45
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated above
46
+ return body as Record<string, unknown>;
47
+ }
48
+ return {};
49
+ }
50
+
51
+ /** Convert a string to a Did, validating at runtime. Returns undefined if invalid. */
52
+ function toDid(value: string): Did | undefined {
53
+ return isDid(value) ? value : undefined;
54
+ }
55
+
56
+ function isLoopbackUrl(url: string): boolean {
57
+ try {
58
+ const { hostname } = new URL(url);
59
+ return hostname === "localhost" || hostname === "127.0.0.1";
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** Normalize scope option to a single space-joined string. Always includes "atproto" as the base. */
66
+ function normalizeScope(scope: string | string[] | undefined): string {
67
+ if (!scope) return "atproto";
68
+ const joined = Array.isArray(scope) ? scope.join(" ") : scope;
69
+ // Ensure the mandatory "atproto" base scope is present
70
+ const parts = joined.split(/\s+/).filter(Boolean);
71
+ if (!parts.includes("atproto")) {
72
+ parts.unshift("atproto");
73
+ }
74
+ return parts.join(" ");
75
+ }
76
+
77
+ function buildMetadata(baseURL: string, options: AtprotoPluginOptions) {
78
+ const isLoopback = isLoopbackUrl(baseURL);
79
+ const callbackPath = options.callbackPath ?? "/atproto/callback";
80
+ const scope = normalizeScope(options.scope);
81
+ const isConfidential = !!options.keyset?.length;
82
+
83
+ // baseURL already includes basePath (e.g. "http://localhost:3456/api/auth"),
84
+ // so append callbackPath to it for the full redirect URI.
85
+ // For loopback, ATProto spec requires 127.0.0.1 (not "localhost") in redirect URIs.
86
+ const redirectUri = isLoopback
87
+ ? `http://127.0.0.1:${new URL(baseURL).port}${new URL(baseURL).pathname}${callbackPath}`
88
+ : `${baseURL}${callbackPath}`;
89
+
90
+ if (isLoopback) {
91
+ // ATProto spec only allows public clients on loopback (client_id must be HTTPS
92
+ // for confidential clients). Keyset is ignored — warn if provided.
93
+ if (isConfidential) {
94
+ console.warn(
95
+ "[atproto] keyset provided but baseURL is loopback — " +
96
+ "falling back to public client mode. Use an HTTPS baseURL for confidential client support.",
97
+ );
98
+ }
99
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- loopback client_id format per ATProto spec
100
+ return {
101
+ redirect_uris: [redirectUri],
102
+ scope,
103
+ } as ReturnType<typeof buildPublicClientMetadata>;
104
+ }
105
+
106
+ if (isConfidential) {
107
+ const clientId = `${baseURL}${options.clientMetadataPath ?? "/oauth-client-metadata.json"}`;
108
+ return {
109
+ client_id: clientId,
110
+ client_name: options.clientName,
111
+ client_uri: options.clientUri,
112
+ logo_uri: options.logoUri,
113
+ tos_uri: options.tosUri,
114
+ policy_uri: options.policyUri,
115
+ redirect_uris: [redirectUri],
116
+ scope,
117
+ grant_types: ["authorization_code", "refresh_token"] as const,
118
+ response_types: ["code"] as const,
119
+ application_type: "web" as const,
120
+ token_endpoint_auth_method: "private_key_jwt" as const,
121
+ dpop_bound_access_tokens: true,
122
+ jwks_uri: `${baseURL}${options.jwksPath ?? "/.well-known/jwks.json"}`,
123
+ };
124
+ }
125
+
126
+ // Discoverable public client (non-loopback, no keyset)
127
+ const clientId = `${baseURL}${options.clientMetadataPath ?? "/oauth-client-metadata.json"}`;
128
+ return buildPublicClientMetadata({
129
+ client_id: clientId,
130
+ client_name: options.clientName,
131
+ client_uri: options.clientUri,
132
+ logo_uri: options.logoUri,
133
+ tos_uri: options.tosUri,
134
+ policy_uri: options.policyUri,
135
+ redirect_uris: [redirectUri],
136
+ scope,
137
+ });
138
+ }
139
+
140
+ function createActorResolver() {
141
+ return new LocalActorResolver({
142
+ handleResolver: new CompositeHandleResolver({
143
+ methods: {
144
+ dns: new DohJsonHandleResolver({
145
+ dohUrl: "https://cloudflare-dns.com/dns-query",
146
+ }),
147
+ http: new WellKnownHandleResolver(),
148
+ },
149
+ }),
150
+ didDocumentResolver: new CompositeDidDocumentResolver({
151
+ methods: {
152
+ plc: new PlcDidDocumentResolver(),
153
+ web: new WebDidDocumentResolver(),
154
+ },
155
+ }),
156
+ });
157
+ }
158
+
159
+ // ────────────────────────── Profile Fetching ──────────────────────────
160
+
161
+ /**
162
+ * Fetch an ATProto profile using the Bluesky public API.
163
+ * This is a fallback when an authenticated session is not available.
164
+ */
165
+ export async function fetchAtprotoProfilePublic(
166
+ did: string,
167
+ ): Promise<AtprotoProfile | null> {
168
+ try {
169
+ const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`;
170
+ const resp = await fetch(url);
171
+ if (!resp.ok) return null;
172
+ const data = await parseJsonResponse(resp);
173
+ return {
174
+ did: getString(data, "did") ?? did,
175
+ handle: getString(data, "handle") ?? did,
176
+ displayName: getString(data, "displayName"),
177
+ avatar: getString(data, "avatar"),
178
+ banner: getString(data, "banner"),
179
+ description: getString(data, "description"),
180
+ };
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Fetch an ATProto profile using an authenticated XRPC client.
188
+ * Falls back to the public API if the authenticated call fails.
189
+ */
190
+ async function fetchAtprotoProfile(
191
+ oauthClient: OAuthClient,
192
+ did: string,
193
+ ): Promise<AtprotoProfile> {
194
+ // Try authenticated fetch first via the OAuth session's DPoP-bound fetch
195
+ try {
196
+ const validDid = toDid(did);
197
+ if (!validDid) throw new Error(`Invalid DID: ${did}`);
198
+ const oauthSession = await oauthClient.restore(validDid);
199
+ const resp = await oauthSession.handle(
200
+ `/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
201
+ );
202
+ if (!resp.ok) throw new Error(`Profile fetch failed: ${resp.status}`);
203
+ const profile = await parseJsonResponse(resp);
204
+ return {
205
+ did: getString(profile, "did") ?? did,
206
+ handle: getString(profile, "handle") ?? did,
207
+ displayName: getString(profile, "displayName"),
208
+ avatar: getString(profile, "avatar"),
209
+ banner: getString(profile, "banner"),
210
+ description: getString(profile, "description"),
211
+ };
212
+ } catch {
213
+ // Fall back to public API
214
+ }
215
+
216
+ const publicProfile = await fetchAtprotoProfilePublic(did);
217
+ if (publicProfile) return publicProfile;
218
+
219
+ // Last resort: return minimal profile with just the DID
220
+ return { did, handle: did };
221
+ }
222
+
223
+ // ────────────────────────── Placeholder Email ──────────────────────────
224
+
225
+ /**
226
+ * Generate a deterministic placeholder email for an ATProto DID.
227
+ * ATProto doesn't expose user emails, but better-auth requires one.
228
+ * Uses the RFC 2606 reserved `.invalid` TLD.
229
+ */
230
+ export function atprotoPlaceholderEmail(did: string): string {
231
+ // Replace colons with underscores for email compatibility
232
+ return `${did.replaceAll(":", "_")}@atproto.invalid`;
233
+ }
234
+
235
+ // ────────────────────────── Error Codes ──────────────────────────
236
+
237
+ const ATPROTO_ERROR_CODES = {
238
+ INVALID_HANDLE: {
239
+ code: "INVALID_HANDLE",
240
+ message: "Invalid ATProto handle or DID",
241
+ },
242
+ AUTHORIZATION_FAILED: {
243
+ code: "AUTHORIZATION_FAILED",
244
+ message: "Failed to start ATProto authorization",
245
+ },
246
+ CALLBACK_FAILED: {
247
+ code: "CALLBACK_FAILED",
248
+ message: "ATProto OAuth callback failed",
249
+ },
250
+ SESSION_NOT_FOUND: {
251
+ code: "SESSION_NOT_FOUND",
252
+ message: "No ATProto session found for the current user",
253
+ },
254
+ SIGNUP_DISABLED: {
255
+ code: "SIGNUP_DISABLED",
256
+ message: "New user registration via ATProto is disabled",
257
+ },
258
+ ACCOUNT_LINKING_DISABLED: {
259
+ code: "ACCOUNT_LINKING_DISABLED",
260
+ message: "Account linking is not enabled",
261
+ },
262
+ };
263
+
264
+ // ────────────────────────── Plugin ──────────────────────────
265
+
266
+ /**
267
+ * ATProto OAuth plugin for better-auth.
268
+ *
269
+ * Integrates ATProto OAuth 2.1 (DPoP + PAR + PKCE) via @atcute/oauth-node-client.
270
+ * Supports both confidential (with keyset) and public client modes.
271
+ */
272
+ export const atproto = (options: AtprotoPluginOptions) => {
273
+ let oauthClient: OAuthClient;
274
+
275
+ const signInPath = options.signInPath ?? "/sign-in/atproto";
276
+ const callbackPath = options.callbackPath ?? "/atproto/callback";
277
+
278
+ return {
279
+ id: "atproto",
280
+
281
+ schema: atprotoSchema,
282
+
283
+ rateLimit: [
284
+ {
285
+ pathMatcher: (path: string) => path === signInPath,
286
+ window: 60,
287
+ max: 5,
288
+ },
289
+ {
290
+ pathMatcher: (path: string) => path === callbackPath,
291
+ window: 60,
292
+ max: 10,
293
+ },
294
+ ],
295
+
296
+ init(ctx: { baseURL: string; adapter: unknown }) {
297
+ const baseURL = ctx.baseURL;
298
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- better-auth types adapter as unknown
299
+ const adapter = ctx.adapter as import("./stores.js").DbAdapter;
300
+
301
+ const sessionStore = new DbSessionStore(adapter);
302
+ const stateStore = new DbStateStore(adapter);
303
+
304
+ const metadata = buildMetadata(baseURL, options);
305
+ const actorResolver = createActorResolver();
306
+
307
+ // Confidential mode only works with HTTPS baseURL — loopback forces public client
308
+ const useConfidential =
309
+ !!options.keyset?.length && !isLoopbackUrl(baseURL);
310
+
311
+ if (useConfidential) {
312
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OAuthClient overloaded constructor
313
+ oauthClient = new OAuthClient({
314
+ metadata,
315
+ keyset: options.keyset!,
316
+ actorResolver,
317
+ stores: { sessions: sessionStore, states: stateStore },
318
+ } as ConstructorParameters<typeof OAuthClient>[0]);
319
+ } else {
320
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- OAuthClient overloaded constructor
321
+ oauthClient = new OAuthClient({
322
+ metadata,
323
+ actorResolver,
324
+ stores: { sessions: sessionStore, states: stateStore },
325
+ } as ConstructorParameters<typeof OAuthClient>[0]);
326
+ }
327
+ },
328
+
329
+ endpoints: {
330
+ // ── Client metadata & JWKS ──
331
+
332
+ atprotoClientMetadata: createAuthEndpoint(
333
+ options.clientMetadataPath ?? "/oauth-client-metadata.json",
334
+ { method: "GET" },
335
+ async (ctx) => {
336
+ return ctx.json(oauthClient.metadata);
337
+ },
338
+ ),
339
+
340
+ atprotoJwks: createAuthEndpoint(
341
+ options.jwksPath ?? "/.well-known/jwks.json",
342
+ { method: "GET" },
343
+ async (ctx) => {
344
+ const jwks = oauthClient.jwks;
345
+ if (!jwks) {
346
+ throw APIError.fromStatus("NOT_FOUND", {
347
+ message: "JWKS not available (public client mode)",
348
+ });
349
+ }
350
+ return ctx.json(jwks);
351
+ },
352
+ ),
353
+
354
+ // ── Sign-in ──
355
+
356
+ signInAtproto: createAuthEndpoint(
357
+ signInPath,
358
+ {
359
+ method: "POST",
360
+ body: v.object({
361
+ handle: v.pipe(
362
+ v.string(),
363
+ v.description("ATProto handle (e.g. user.bsky.social) or DID"),
364
+ ),
365
+ callbackURL: v.optional(
366
+ v.pipe(
367
+ v.string(),
368
+ v.description("URL to redirect to after sign-in"),
369
+ ),
370
+ ),
371
+ }),
372
+ },
373
+ async (ctx) => {
374
+ const { handle, callbackURL } = ctx.body;
375
+
376
+ if (!handle || handle.length < 3) {
377
+ throw APIError.from(
378
+ "BAD_REQUEST",
379
+ ATPROTO_ERROR_CODES.INVALID_HANDLE,
380
+ );
381
+ }
382
+
383
+ try {
384
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated above
385
+ const identifier = handle as `${string}.${string}`;
386
+ const result = await oauthClient.authorize({
387
+ target: {
388
+ type: "account",
389
+ identifier,
390
+ },
391
+ state: callbackURL ? JSON.stringify({ callbackURL }) : undefined,
392
+ });
393
+
394
+ return ctx.json({
395
+ url: result.url.toString(),
396
+ redirect: true,
397
+ });
398
+ } catch (e) {
399
+ console.error("[atproto] authorize failed:", e);
400
+ throw APIError.from(
401
+ "INTERNAL_SERVER_ERROR",
402
+ ATPROTO_ERROR_CODES.AUTHORIZATION_FAILED,
403
+ );
404
+ }
405
+ },
406
+ ),
407
+
408
+ // ── OAuth callback ──
409
+
410
+ atprotoCallback: createAuthEndpoint(
411
+ callbackPath,
412
+ {
413
+ method: "GET",
414
+ query: v.object({
415
+ code: v.optional(v.string()),
416
+ state: v.optional(v.string()),
417
+ iss: v.optional(v.string()),
418
+ error: v.optional(v.string()),
419
+ error_description: v.optional(v.string()),
420
+ }),
421
+ },
422
+ async (ctx) => {
423
+ if (ctx.query.error) {
424
+ const errorUrl = `${ctx.context.baseURL}/error?error=${ctx.query.error}`;
425
+ throw ctx.redirect(errorUrl);
426
+ }
427
+
428
+ try {
429
+ const params = new URLSearchParams();
430
+ if (ctx.query.code) params.set("code", ctx.query.code);
431
+ if (ctx.query.state) params.set("state", ctx.query.state);
432
+ if (ctx.query.iss) params.set("iss", ctx.query.iss);
433
+
434
+ const { session: oauthSession, state: userState } =
435
+ await oauthClient.callback(params);
436
+
437
+ const did = oauthSession.did;
438
+
439
+ // Resolve PDS URL from the session's token info
440
+ const tokenInfo = await oauthSession.getTokenInfo(false);
441
+ const pdsUrl = tokenInfo.aud;
442
+
443
+ // Fetch full profile (authenticated, then public fallback)
444
+ const profile = await fetchAtprotoProfile(oauthClient, did);
445
+
446
+ // Apply custom profile mapping (or defaults)
447
+ const mappedFields = options.mapProfileToUser
448
+ ? options.mapProfileToUser(profile)
449
+ : {
450
+ name: profile.displayName || profile.handle,
451
+ image: profile.avatar,
452
+ };
453
+
454
+ const email = mappedFields.email || atprotoPlaceholderEmail(did);
455
+
456
+ // ── Determine user: existing account, account linking, or new user ──
457
+
458
+ // 1. Check for existing atproto account by DID
459
+ const existingAccount =
460
+ await ctx.context.internalAdapter.findAccountByProviderId(
461
+ did,
462
+ "atproto",
463
+ );
464
+
465
+ let userId: string;
466
+
467
+ if (existingAccount) {
468
+ // Existing ATProto user — update profile fields
469
+ userId = existingAccount.userId;
470
+ await ctx.context.internalAdapter.updateUser(userId, {
471
+ name: mappedFields.name,
472
+ image: mappedFields.image,
473
+ atprotoDid: did,
474
+ atprotoHandle: profile.handle,
475
+ });
476
+ } else {
477
+ // 2. Check if user is currently logged in (account linking scenario)
478
+ const { getSessionFromCtx } = await import("better-auth/api");
479
+ const currentSession = await getSessionFromCtx(ctx).catch(
480
+ () => null,
481
+ );
482
+
483
+ if (currentSession) {
484
+ // User is logged in — try to link the ATProto account
485
+ const linkingEnabled =
486
+ ctx.context.options.account?.accountLinking?.enabled !==
487
+ false;
488
+
489
+ if (!linkingEnabled) {
490
+ throw APIError.from(
491
+ "FORBIDDEN",
492
+ ATPROTO_ERROR_CODES.ACCOUNT_LINKING_DISABLED,
493
+ );
494
+ }
495
+
496
+ await ctx.context.internalAdapter.linkAccount({
497
+ userId: currentSession.user.id,
498
+ providerId: "atproto",
499
+ accountId: did,
500
+ accessToken: "atproto-session",
501
+ refreshToken: "atproto-session",
502
+ scope: normalizeScope(options.scope),
503
+ });
504
+
505
+ userId = currentSession.user.id;
506
+
507
+ // Update profile fields on the existing user
508
+ await ctx.context.internalAdapter.updateUser(userId, {
509
+ atprotoDid: did,
510
+ atprotoHandle: profile.handle,
511
+ ...(mappedFields.image ? { image: mappedFields.image } : {}),
512
+ });
513
+ } else {
514
+ // 3. No existing account, no current session — create new user
515
+ if (options.disableSignUp) {
516
+ throw APIError.from(
517
+ "FORBIDDEN",
518
+ ATPROTO_ERROR_CODES.SIGNUP_DISABLED,
519
+ );
520
+ }
521
+
522
+ const newUser = await ctx.context.internalAdapter.createUser({
523
+ name: mappedFields.name || profile.handle,
524
+ email,
525
+ emailVerified: false,
526
+ image: mappedFields.image || null,
527
+ atprotoDid: did,
528
+ atprotoHandle: profile.handle,
529
+ createdAt: new Date(),
530
+ updatedAt: new Date(),
531
+ });
532
+
533
+ await ctx.context.internalAdapter.createAccount({
534
+ userId: newUser.id,
535
+ providerId: "atproto",
536
+ accountId: did,
537
+ accessToken: "atproto-session",
538
+ refreshToken: "atproto-session",
539
+ scope: normalizeScope(options.scope),
540
+ });
541
+
542
+ userId = newUser.id;
543
+ }
544
+ }
545
+
546
+ // ── Update atprotoSession with user info ──
547
+
548
+ const existingAtprotoSession = await ctx.context.adapter.findOne<{
549
+ id: string;
550
+ }>({
551
+ model: "atprotoSession",
552
+ where: [{ field: "did", value: did }],
553
+ });
554
+
555
+ if (existingAtprotoSession) {
556
+ await ctx.context.adapter.update({
557
+ model: "atprotoSession",
558
+ where: [{ field: "did", value: did }],
559
+ update: {
560
+ userId,
561
+ handle: profile.handle,
562
+ pdsUrl,
563
+ updatedAt: new Date(),
564
+ },
565
+ });
566
+ } else {
567
+ await ctx.context.adapter.create({
568
+ model: "atprotoSession",
569
+ data: {
570
+ did,
571
+ sessionData: "{}",
572
+ userId,
573
+ handle: profile.handle,
574
+ pdsUrl,
575
+ updatedAt: new Date(),
576
+ },
577
+ });
578
+ }
579
+
580
+ // ── Create better-auth session ──
581
+
582
+ const foundUser =
583
+ await ctx.context.internalAdapter.findUserById(userId);
584
+ if (!foundUser) {
585
+ throw APIError.from(
586
+ "INTERNAL_SERVER_ERROR",
587
+ ATPROTO_ERROR_CODES.CALLBACK_FAILED,
588
+ );
589
+ }
590
+
591
+ const session =
592
+ await ctx.context.internalAdapter.createSession(userId);
593
+
594
+ await setSessionCookie(ctx, {
595
+ session,
596
+ user: foundUser,
597
+ });
598
+
599
+ // Parse callbackURL from state
600
+ let callbackURL = "/";
601
+ if (userState) {
602
+ try {
603
+ const parsed =
604
+ typeof userState === "string"
605
+ ? JSON.parse(userState)
606
+ : userState;
607
+ if (
608
+ parsed.callbackURL &&
609
+ typeof parsed.callbackURL === "string"
610
+ ) {
611
+ callbackURL = parsed.callbackURL;
612
+ }
613
+ } catch {
614
+ // Ignore parse errors
615
+ }
616
+ }
617
+
618
+ // Validate redirect URL to prevent open redirects
619
+ if (!callbackURL.startsWith("/") || callbackURL.startsWith("//")) {
620
+ callbackURL = "/";
621
+ }
622
+
623
+ throw ctx.redirect(callbackURL);
624
+ } catch (e) {
625
+ // Re-throw redirects and API responses
626
+ if (
627
+ e &&
628
+ typeof e === "object" &&
629
+ ("statusCode" in e || "status" in e)
630
+ ) {
631
+ throw e;
632
+ }
633
+ if (e instanceof OAuthCallbackError) {
634
+ const errorUrl = `${ctx.context.baseURL}/error?error=${e.error}`;
635
+ throw ctx.redirect(errorUrl);
636
+ }
637
+ throw APIError.from(
638
+ "INTERNAL_SERVER_ERROR",
639
+ ATPROTO_ERROR_CODES.CALLBACK_FAILED,
640
+ );
641
+ }
642
+ },
643
+ ),
644
+
645
+ // ── Get session (returns ATProto info for the current user) ──
646
+
647
+ atprotoGetSession: createAuthEndpoint(
648
+ "/atproto/session",
649
+ { method: "GET" },
650
+ async (ctx) => {
651
+ const { getSessionFromCtx } = await import("better-auth/api");
652
+ const currentSession = await getSessionFromCtx(ctx);
653
+ if (!currentSession) {
654
+ throw APIError.fromStatus("UNAUTHORIZED", {
655
+ message: "Not authenticated",
656
+ });
657
+ }
658
+
659
+ const atprotoSession = await ctx.context.adapter.findOne<{
660
+ did: string;
661
+ handle: string;
662
+ pdsUrl: string;
663
+ }>({
664
+ model: "atprotoSession",
665
+ where: [
666
+ {
667
+ field: "userId",
668
+ value: currentSession.user.id,
669
+ },
670
+ ],
671
+ });
672
+
673
+ if (!atprotoSession) {
674
+ throw APIError.from(
675
+ "NOT_FOUND",
676
+ ATPROTO_ERROR_CODES.SESSION_NOT_FOUND,
677
+ );
678
+ }
679
+
680
+ // Include profile fields from the user record
681
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- plugin schema extends user
682
+ const user = currentSession.user as Record<string, unknown>;
683
+
684
+ return ctx.json({
685
+ did: atprotoSession.did,
686
+ handle: atprotoSession.handle,
687
+ pdsUrl: atprotoSession.pdsUrl,
688
+ atprotoDid: getString(user, "atprotoDid") ?? null,
689
+ atprotoHandle: getString(user, "atprotoHandle") ?? null,
690
+ });
691
+ },
692
+ ),
693
+
694
+ // ── Restore (lightweight session check without full profile fetch) ──
695
+
696
+ atprotoRestore: createAuthEndpoint(
697
+ "/atproto/restore",
698
+ { method: "POST" },
699
+ async (ctx) => {
700
+ const { getSessionFromCtx } = await import("better-auth/api");
701
+ const currentSession = await getSessionFromCtx(ctx);
702
+ if (!currentSession) {
703
+ throw APIError.fromStatus("UNAUTHORIZED", {
704
+ message: "Not authenticated",
705
+ });
706
+ }
707
+
708
+ const atprotoSession = await ctx.context.adapter.findOne<{
709
+ did: string;
710
+ handle: string;
711
+ pdsUrl: string;
712
+ }>({
713
+ model: "atprotoSession",
714
+ where: [{ field: "userId", value: currentSession.user.id }],
715
+ });
716
+
717
+ if (!atprotoSession) {
718
+ return ctx.json({ active: false });
719
+ }
720
+
721
+ // Try to restore the OAuth session (token refresh if needed)
722
+ try {
723
+ const validDid = toDid(atprotoSession.did);
724
+ if (!validDid) return ctx.json({ active: false });
725
+ await oauthClient.restore(validDid);
726
+ return ctx.json({
727
+ active: true,
728
+ did: atprotoSession.did,
729
+ handle: atprotoSession.handle,
730
+ });
731
+ } catch {
732
+ return ctx.json({ active: false });
733
+ }
734
+ },
735
+ ),
736
+
737
+ // ── Sign out (revoke ATProto session) ──
738
+
739
+ atprotoSignOut: createAuthEndpoint(
740
+ "/atproto/sign-out",
741
+ { method: "POST" },
742
+ async (ctx) => {
743
+ const { getSessionFromCtx } = await import("better-auth/api");
744
+ const currentSession = await getSessionFromCtx(ctx);
745
+ if (!currentSession) {
746
+ throw APIError.fromStatus("UNAUTHORIZED", {
747
+ message: "Not authenticated",
748
+ });
749
+ }
750
+
751
+ const atprotoSession = await ctx.context.adapter.findOne<{
752
+ did: string;
753
+ }>({
754
+ model: "atprotoSession",
755
+ where: [
756
+ {
757
+ field: "userId",
758
+ value: currentSession.user.id,
759
+ },
760
+ ],
761
+ });
762
+
763
+ if (atprotoSession) {
764
+ try {
765
+ const validDid = toDid(atprotoSession.did);
766
+ if (validDid) await oauthClient.revoke(validDid);
767
+ } catch {
768
+ // Best effort revocation
769
+ }
770
+ }
771
+
772
+ return ctx.json({ success: true });
773
+ },
774
+ ),
775
+
776
+ // ── Server-only: get authenticated XRPC client ──
777
+ // Path-less endpoint — only callable via auth.api.getAtprotoClient(),
778
+ // not exposed over HTTP, not inferred as a client action.
779
+
780
+ getAtprotoClient: createAuthEndpoint(
781
+ {
782
+ method: "POST",
783
+ body: v.object({
784
+ did: v.optional(v.string()),
785
+ userId: v.optional(v.string()),
786
+ }),
787
+ metadata: { SERVER_ONLY: true as const },
788
+ },
789
+ async (ctx) => {
790
+ const { did: inputDid, userId } = ctx.body;
791
+
792
+ let did: string | undefined = inputDid;
793
+
794
+ if (!did && userId) {
795
+ const atprotoSession = await ctx.context.adapter.findOne<{
796
+ did: string;
797
+ }>({
798
+ model: "atprotoSession",
799
+ where: [{ field: "userId", value: userId }],
800
+ });
801
+ if (atprotoSession) {
802
+ did = atprotoSession.did;
803
+ }
804
+ }
805
+
806
+ if (!did) {
807
+ throw APIError.from(
808
+ "BAD_REQUEST",
809
+ ATPROTO_ERROR_CODES.SESSION_NOT_FOUND,
810
+ );
811
+ }
812
+
813
+ const validDid = toDid(did);
814
+ if (!validDid) {
815
+ throw APIError.from(
816
+ "BAD_REQUEST",
817
+ ATPROTO_ERROR_CODES.INVALID_HANDLE,
818
+ );
819
+ }
820
+
821
+ const oauthSession = await oauthClient.restore(validDid);
822
+ const client = new Client({ handler: oauthSession });
823
+
824
+ return { client, session: oauthSession };
825
+ },
826
+ ),
827
+ },
828
+
829
+ $ERROR_CODES: ATPROTO_ERROR_CODES,
830
+ } satisfies BetterAuthPlugin;
831
+ };