Sync your Obsidian vault to an atproto PDS community.obsidian.md/plugins/pds-sync
0

Configure Feed

Select the types of activity you want to include in your feed.

atproto: add XRPC client facade and OAuth/app-password auth

author
moshyfawn
date (Jun 10, 2026, 2:51 PM -0400) commit 5a3a109a parent 948ee8fa change-id qrmxvovl
+298
+98
src/atproto/auth.ts
··· 1 + import { Client } from "@atcute/client"; 2 + import { PasswordSession } from "@atcute/password-session"; 3 + import { 4 + configureOAuth, 5 + createAuthorizationUrl, 6 + finalizeAuthorization, 7 + getSession, 8 + OAuthUserAgent, 9 + } from "@atcute/oauth-browser-client"; 10 + import { 11 + CompositeDidDocumentResolver, 12 + CompositeHandleResolver, 13 + DohJsonHandleResolver, 14 + LocalActorResolver, 15 + PlcDidDocumentResolver, 16 + WebDidDocumentResolver, 17 + WellKnownHandleResolver, 18 + } from "@atcute/identity-resolver"; 19 + 20 + /** 21 + * Auth strategies, both producing an authenticated @atcute/client `Client`. 22 + * 23 + * - App password: PasswordSession (works everywhere; legacy but simple). Tokens 24 + * live in memory only - we re-login from the keychain'd password each launch 25 + * rather than persisting JWTs to disk. 26 + * - OAuth: the no-backend public-client flow (PKCE + DPoP-bound PAR), with the 27 + * atproto-mandated HTTPS redirect bouncing back into Obsidian via obsidian://. 28 + * 29 + */ 30 + 31 + export interface AuthResult { 32 + rpc: Client; 33 + did: string; 34 + handle?: string; 35 + } 36 + 37 + // Resolve handle -> DID without an appview: DNS-over-HTTPS (CORS-friendly for Electron env) raced 38 + // against the handle domain's /.well-known/atproto-did. DID docs resolve via PLC/web. 39 + const DOH_URL = "https://cloudflare-dns.com/dns-query"; 40 + 41 + /** Idempotent - safe to call repeatedly. */ 42 + export function setupOAuth(clientId: string, redirectUri: string): void { 43 + configureOAuth({ 44 + metadata: { client_id: clientId, redirect_uri: redirectUri }, 45 + identityResolver: new LocalActorResolver({ 46 + handleResolver: new CompositeHandleResolver({ 47 + strategy: "race", 48 + methods: { 49 + dns: new DohJsonHandleResolver({ dohUrl: DOH_URL }), 50 + http: new WellKnownHandleResolver(), 51 + }, 52 + }), 53 + didDocumentResolver: new CompositeDidDocumentResolver({ 54 + methods: { 55 + plc: new PlcDidDocumentResolver(), 56 + web: new WebDidDocumentResolver(), 57 + }, 58 + }), 59 + }), 60 + }); 61 + } 62 + 63 + export async function startOAuthLogin( 64 + identifier: string, 65 + scope: string, 66 + ): Promise<URL> { 67 + return createAuthorizationUrl({ 68 + target: { type: "account", identifier: identifier as never }, 69 + scope, 70 + }); 71 + } 72 + 73 + export async function finishOAuthLogin( 74 + params: URLSearchParams, 75 + ): Promise<AuthResult> { 76 + const { session } = await finalizeAuthorization(params); 77 + const agent = new OAuthUserAgent(session); 78 + return { rpc: new Client({ handler: agent }), did: agent.sub }; 79 + } 80 + 81 + export async function resumeOAuth(did: string): Promise<AuthResult> { 82 + const session = await getSession(did as never, { allowStale: true }); 83 + const agent = new OAuthUserAgent(session); 84 + return { rpc: new Client({ handler: agent }), did: agent.sub }; 85 + } 86 + 87 + export async function loginPassword(creds: { 88 + service: string; 89 + identifier: string; 90 + password: string; 91 + }): Promise<AuthResult> { 92 + const session = await PasswordSession.login(creds); 93 + return { 94 + rpc: new Client({ handler: session }), 95 + did: session.did, 96 + handle: session.session.handle, 97 + }; 98 + }
+200
src/atproto/client.ts
··· 1 + import { Client } from "@atcute/client"; 2 + // Registers the com.atproto.* lexicon types so the XRPC calls below typecheck. 3 + import type {} from "@atcute/atproto"; 4 + 5 + /** 6 + * Thin facade over @atcute/client for the repo ops the sync engine needs. 7 + * Auth + transport live in the `handler` (PasswordSession / OAuthUserAgent, see 8 + * auth.ts); errors surface as XrpcError so callers can drive compare-and-swap recovery. 9 + */ 10 + 11 + export interface WriteResult { 12 + uri: string; 13 + cid: string; 14 + } 15 + 16 + export interface ListedRecord { 17 + uri: string; 18 + cid: string; 19 + value: Record<string, unknown>; 20 + } 21 + 22 + /** An XRPC call that returned a lexicon error; `code` is the atproto error name (e.g. "InvalidSwap"). */ 23 + export class XrpcError extends Error { 24 + constructor( 25 + readonly code: string | undefined, 26 + message: string, 27 + ) { 28 + super(message); 29 + this.name = "XrpcError"; 30 + } 31 + } 32 + 33 + interface XrpcResponse { 34 + ok: boolean; 35 + status: number; 36 + data: unknown; 37 + } 38 + 39 + export class AtpClient { 40 + private rpc: Client | null = null; 41 + private _did: string | null = null; 42 + private _handle: string | null = null; 43 + 44 + attach(rpc: Client, did: string, handle?: string): void { 45 + this.rpc = rpc; 46 + this._did = did; 47 + this._handle = handle ?? null; 48 + } 49 + 50 + detach(): void { 51 + this.rpc = null; 52 + this._did = null; 53 + this._handle = null; 54 + } 55 + 56 + get did(): string | null { 57 + return this._did; 58 + } 59 + 60 + get handle(): string | null { 61 + return this._handle; 62 + } 63 + 64 + get isLoggedIn(): boolean { 65 + return this.rpc !== null && this._did !== null; 66 + } 67 + 68 + async ensureHandle(): Promise<void> { 69 + if (this._handle || !this.rpc || !this._did) return; 70 + const res = (await this.rpc.get("com.atproto.repo.describeRepo", { 71 + params: { repo: this._did } as never, 72 + })) as XrpcResponse; 73 + if (res.ok) { 74 + const handle = (res.data as { handle?: unknown }).handle; 75 + if (typeof handle === "string") this._handle = handle; 76 + } 77 + } 78 + 79 + async createRecord( 80 + collection: string, 81 + record: Record<string, unknown>, 82 + rkey?: string, 83 + ): Promise<WriteResult> { 84 + const { rpc, did } = this.ctx(); 85 + const input: Record<string, unknown> = { 86 + repo: did, 87 + collection, 88 + record, 89 + }; 90 + if (rkey) input.rkey = rkey; 91 + const res = (await rpc.post("com.atproto.repo.createRecord", { 92 + input: input as never, 93 + })) as XrpcResponse; 94 + return this.unwrap<WriteResult>(res, "createRecord"); 95 + } 96 + 97 + async putRecord( 98 + collection: string, 99 + rkey: string, 100 + record: Record<string, unknown>, 101 + swapRecord?: string, 102 + ): Promise<WriteResult> { 103 + const { rpc, did } = this.ctx(); 104 + const input: Record<string, unknown> = { 105 + repo: did, 106 + collection, 107 + rkey, 108 + record, 109 + }; 110 + if (swapRecord) input.swapRecord = swapRecord; 111 + const res = (await rpc.post("com.atproto.repo.putRecord", { 112 + input: input as never, 113 + })) as XrpcResponse; 114 + return this.unwrap<WriteResult>(res, "putRecord"); 115 + } 116 + 117 + /** Returns the opaque blob ref to embed in a record (e.g. a publication icon). */ 118 + async uploadBlob(bytes: ArrayBuffer, mime: string): Promise<unknown> { 119 + const { rpc } = this.ctx(); 120 + const blob = new Blob([bytes], { type: mime }); 121 + const res = (await rpc.post("com.atproto.repo.uploadBlob", { 122 + input: blob as never, 123 + })) as XrpcResponse; 124 + return this.unwrap<{ blob: unknown }>(res, "uploadBlob").blob; 125 + } 126 + 127 + async deleteRecord(collection: string, rkey: string): Promise<void> { 128 + const { rpc, did } = this.ctx(); 129 + const res = (await rpc.post("com.atproto.repo.deleteRecord", { 130 + input: { repo: did, collection, rkey } as never, 131 + })) as XrpcResponse; 132 + this.unwrap(res, "deleteRecord"); 133 + } 134 + 135 + async listRecords( 136 + collection: string, 137 + opts: { limit?: number; cursor?: string } = {}, 138 + ): Promise<{ records: ListedRecord[]; cursor?: string }> { 139 + const { rpc, did } = this.ctx(); 140 + const params: Record<string, unknown> = { 141 + repo: did, 142 + collection, 143 + limit: opts.limit ?? 50, 144 + }; 145 + if (opts.cursor) params.cursor = opts.cursor; 146 + const res = (await rpc.get("com.atproto.repo.listRecords", { 147 + params: params as never, 148 + })) as XrpcResponse; 149 + const data = this.unwrap<{ records?: ListedRecord[]; cursor?: string }>( 150 + res, 151 + "listRecords", 152 + ); 153 + return { records: data.records ?? [], cursor: data.cursor }; 154 + } 155 + 156 + /** Fetch a single record, or null if it doesn't exist (used for CAS disambiguation). */ 157 + async getRecordRaw( 158 + collection: string, 159 + rkey: string, 160 + ): Promise<{ 161 + uri: string; 162 + cid: string; 163 + value: Record<string, unknown>; 164 + } | null> { 165 + const { rpc, did } = this.ctx(); 166 + const res = (await rpc.get("com.atproto.repo.getRecord", { 167 + params: { repo: did, collection, rkey } as never, 168 + })) as XrpcResponse; 169 + if (!res.ok) { 170 + const code = (res.data as { error?: string } | undefined)?.error; 171 + if (res.status === 404 || code === "RecordNotFound") return null; 172 + throw new XrpcError( 173 + code, 174 + `getRecord (${res.status}): ${code ?? "request failed"}`, 175 + ); 176 + } 177 + const d = res.data as { 178 + uri: string; 179 + cid?: string; 180 + value: Record<string, unknown>; 181 + }; 182 + return { uri: d.uri, cid: d.cid ?? "", value: d.value }; 183 + } 184 + 185 + private ctx(): { rpc: Client; did: string } { 186 + if (!this.rpc || !this._did) { 187 + throw new Error("Not connected - sign in from PDS Sync settings."); 188 + } 189 + return { rpc: this.rpc, did: this._did }; 190 + } 191 + 192 + private unwrap<T>(res: XrpcResponse, nsid: string): T { 193 + if (res.ok) return res.data as T; 194 + const d = res.data as { error?: string; message?: string } | undefined; 195 + throw new XrpcError( 196 + d?.error, 197 + `${nsid} (${res.status}): ${d?.message ?? d?.error ?? "request failed"}`, 198 + ); 199 + } 200 + }