diff --git a/docs/docs/pages/workflows.mdx b/docs/docs/pages/workflows.mdx index 69963ed..ca104a5 100644 --- a/docs/docs/pages/workflows.mdx +++ b/docs/docs/pages/workflows.mdx @@ -73,6 +73,8 @@ Sequoia supports environment variables for automation scenarios like CD/CI. | `PDS_URL` | Custom PDS URL (optional, auto-resolved from DID if not set) | | `SEQUOIA_PROFILE` | Name of a stored identity profile to use | +When `PDS_URL` is not set, Sequoia resolves `ATP_IDENTIFIER` to its DID document and uses the PDS listed there, so self-hosted PDSs work without extra configuration. Set `PDS_URL` explicitly if you authenticate with an email address instead of a handle. + ## CI/CD Integration Sequoia works with any CI/CD platform. Set `ATP_IDENTIFIER` and `ATP_APP_PASSWORD` as secrets in your pipeline. diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 10334a6..d2f003f 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -9,7 +9,7 @@ import { text, } from "@clack/prompts"; import { command, flag, option, optional, string } from "cmd-ts"; -import { resolveHandleToPDS } from "../lib/atproto"; +import { resolveHandleToPDS } from "../lib/identity"; import { deleteCredentials, getCredentials, diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 998f693..81bd242 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -1,7 +1,7 @@ import * as http from "node:http"; import { log, note, select, spinner, text } from "@clack/prompts"; import { command, flag, option, optional, string } from "cmd-ts"; -import { resolveHandleToDid } from "../lib/atproto"; +import { resolveHandleToDid } from "../lib/identity"; import { getCallbackPort, getOAuthClient, diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index f3b4c7b..46fa7fd 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -45,72 +45,6 @@ async function fileExists(filePath: string): Promise { } } -/** - * Resolve a handle to a DID - */ -export async function resolveHandleToDid(handle: string): Promise { - if (handle.startsWith("did:")) { - return handle; - } - - // Try to resolve handle via Bluesky API - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; - const resolveResponse = await fetch(resolveUrl); - if (!resolveResponse.ok) { - throw new Error("Could not resolve handle"); - } - const resolveData = (await resolveResponse.json()) as { did: string }; - return resolveData.did; -} - -export async function resolveHandleToPDS(handle: string): Promise { - // First, resolve the handle to a DID - const did = await resolveHandleToDid(handle); - - // Now resolve the DID to get the PDS URL from the DID document - let pdsUrl: string | undefined; - - if (did.startsWith("did:plc:")) { - // Fetch DID document from plc.directory - const didDocUrl = `https://plc.directory/${did}`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error("Could not fetch DID document"); - } - const didDoc = (await didDocResponse.json()) as { - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; - }; - - // Find the PDS service endpoint - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else if (did.startsWith("did:web:")) { - // For did:web, fetch the DID document from the domain - const domain = did.replace("did:web:", ""); - const didDocUrl = `https://${domain}/.well-known/did.json`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error("Could not fetch DID document"); - } - const didDoc = (await didDocResponse.json()) as { - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; - }; - - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } - - if (!pdsUrl) { - throw new Error("Could not find PDS URL for user"); - } - - return pdsUrl; -} - export interface CreatePublicationOptions { url: string; name: string; diff --git a/packages/cli/src/lib/credentials.ts b/packages/cli/src/lib/credentials.ts index a7dc217..7b00580 100644 --- a/packages/cli/src/lib/credentials.ts +++ b/packages/cli/src/lib/credentials.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { resolvePdsUrlOrDefault } from "./identity"; import { getOAuthHandle, getOAuthSession, @@ -118,7 +119,8 @@ async function tryLoadOAuthCredentials( * Load credentials for a specific identity or resolve which to use. * * Priority: - * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) + * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD), with the PDS taken + * from PDS_URL or resolved from the identifier * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) * 3. projectIdentity parameter (from sequoia.json) * 4. If only one identity stored (app-password or OAuth), use it @@ -137,7 +139,9 @@ export async function loadCredentials( type: "app-password", identifier: envIdentifier, password: envPassword, - pdsUrl: envPdsUrl || "https://bsky.social", + // Without an explicit PDS_URL, resolve the PDS from the identifier's + // DID document so self-hosted PDSs work out of the box + pdsUrl: envPdsUrl || (await resolvePdsUrlOrDefault(envIdentifier)), }; } diff --git a/packages/cli/src/lib/identity.ts b/packages/cli/src/lib/identity.ts new file mode 100644 index 0000000..c0a882b --- /dev/null +++ b/packages/cli/src/lib/identity.ts @@ -0,0 +1,139 @@ +export const DEFAULT_PDS_URL = "https://bsky.social"; + +interface DidDocument { + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; +} + +/** + * Normalize a user supplied identifier (strips a leading "@", trims, lowercases) + */ +function normalizeIdentifier(identifier: string): string { + return identifier.trim().replace(/^@/, "").toLowerCase(); +} + +/** + * Check whether an identifier can be resolved through the identity system. + * App passwords also accept an email address, which has no DID document. + */ +export function isResolvableIdentifier(identifier: string): boolean { + const normalized = normalizeIdentifier(identifier); + if (normalized.startsWith("did:")) return true; + // Handles are domain names, emails are not + return normalized.includes(".") && !normalized.includes("@"); +} + +/** + * Find the PDS service endpoint in a DID document + */ +function getPdsEndpoint(didDoc: DidDocument): string | undefined { + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + return pdsService?.serviceEndpoint; +} + +/** + * Resolve a handle to a DID + */ +export async function resolveHandleToDid(handle: string): Promise { + const normalized = normalizeIdentifier(handle); + if (normalized.startsWith("did:")) { + return normalized; + } + + // Try to resolve handle via Bluesky API + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(normalized)}`; + try { + const resolveResponse = await fetch(resolveUrl); + if (resolveResponse.ok) { + const resolveData = (await resolveResponse.json()) as { did: string }; + if (resolveData.did) { + return resolveData.did; + } + } + } catch { + // Fall through to the well-known lookup below + } + + // Fall back to the handle's own domain, which self-hosted handles may serve + // even when they are unknown to the Bluesky appview + try { + const wellKnownResponse = await fetch( + `https://${normalized}/.well-known/atproto-did`, + ); + if (wellKnownResponse.ok) { + const did = (await wellKnownResponse.text()).trim(); + if (did.startsWith("did:")) { + return did; + } + } + } catch { + // Fall through to the error below + } + + throw new Error("Could not resolve handle"); +} + +/** + * Resolve a DID to the PDS URL listed in its DID document + */ +export async function resolveDidToPDS(did: string): Promise { + let pdsUrl: string | undefined; + + if (did.startsWith("did:plc:")) { + // Fetch DID document from plc.directory + const didDocUrl = `https://plc.directory/${did}`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error("Could not fetch DID document"); + } + pdsUrl = getPdsEndpoint((await didDocResponse.json()) as DidDocument); + } else if (did.startsWith("did:web:")) { + // For did:web, fetch the DID document from the domain + const domain = did.replace("did:web:", ""); + const didDocUrl = `https://${domain}/.well-known/did.json`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error("Could not fetch DID document"); + } + pdsUrl = getPdsEndpoint((await didDocResponse.json()) as DidDocument); + } + + if (!pdsUrl) { + throw new Error("Could not find PDS URL for user"); + } + + return pdsUrl; +} + +export async function resolveHandleToPDS(handle: string): Promise { + // First, resolve the handle to a DID + const did = await resolveHandleToDid(handle); + + // Now resolve the DID to get the PDS URL from the DID document + return resolveDidToPDS(did); +} + +/** + * Resolve the PDS for an identifier, falling back to the default PDS when the + * identifier can't be resolved (e.g. an email address, or a network failure). + */ +export async function resolvePdsUrlOrDefault( + identifier: string, +): Promise { + if (!isResolvableIdentifier(identifier)) { + console.warn( + `Could not resolve a PDS from "${identifier}", falling back to ${DEFAULT_PDS_URL}. Set PDS_URL if you use another PDS.`, + ); + return DEFAULT_PDS_URL; + } + + try { + return await resolveHandleToPDS(identifier); + } catch (error) { + console.warn( + `Could not resolve a PDS for "${identifier}" (${error instanceof Error ? error.message : error}), falling back to ${DEFAULT_PDS_URL}. Set PDS_URL if you use another PDS.`, + ); + return DEFAULT_PDS_URL; + } +} diff --git a/packages/cli/test/credentials.test.ts b/packages/cli/test/credentials.test.ts new file mode 100644 index 0000000..cbdb0a6 --- /dev/null +++ b/packages/cli/test/credentials.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { loadCredentials } from "../src/lib/credentials"; + +const realFetch = globalThis.fetch; +const envKeys = ["ATP_IDENTIFIER", "ATP_APP_PASSWORD", "PDS_URL"] as const; +const savedEnv = envKeys.map((key) => [key, process.env[key]] as const); + +function mockFetch(handler: (url: string) => Response): void { + globalThis.fetch = (async (input: RequestInfo | URL) => + handler( + typeof input === "string" ? input : input.toString(), + )) as typeof fetch; +} + +afterEach(() => { + globalThis.fetch = realFetch; + for (const [key, value] of savedEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("loadCredentials with app-password env vars", () => { + it("resolves the PDS from the identifier when PDS_URL is unset", async () => { + process.env.ATP_IDENTIFIER = "alice.example.com"; + process.env.ATP_APP_PASSWORD = "app-password"; + delete process.env.PDS_URL; + + mockFetch((url) => + url.includes("plc.directory") + ? Response.json({ + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example.com", + }, + ], + }) + : Response.json({ did: "did:plc:abc123" }), + ); + + const credentials = await loadCredentials(); + + expect(credentials).toEqual({ + type: "app-password", + identifier: "alice.example.com", + password: "app-password", + pdsUrl: "https://pds.example.com", + }); + }); + + it("prefers an explicit PDS_URL without any lookup", async () => { + process.env.ATP_IDENTIFIER = "alice.example.com"; + process.env.ATP_APP_PASSWORD = "app-password"; + process.env.PDS_URL = "https://custom.example.com"; + + mockFetch(() => { + throw new Error("should not fetch"); + }); + + const credentials = await loadCredentials(); + + expect(credentials).toMatchObject({ + pdsUrl: "https://custom.example.com", + }); + }); + + it("falls back to bsky.social when the identifier cannot be resolved", async () => { + process.env.ATP_IDENTIFIER = "alice.example.com"; + process.env.ATP_APP_PASSWORD = "app-password"; + delete process.env.PDS_URL; + + mockFetch(() => new Response("nope", { status: 500 })); + + const credentials = await loadCredentials(); + + expect(credentials).toMatchObject({ pdsUrl: "https://bsky.social" }); + }); +}); diff --git a/packages/cli/test/identity.test.ts b/packages/cli/test/identity.test.ts new file mode 100644 index 0000000..93c253f --- /dev/null +++ b/packages/cli/test/identity.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + DEFAULT_PDS_URL, + isResolvableIdentifier, + resolveHandleToDid, + resolveHandleToPDS, + resolvePdsUrlOrDefault, +} from "../src/lib/identity"; + +const realFetch = globalThis.fetch; + +type FetchHandler = (url: string) => Response | Promise; + +function mockFetch(handler: FetchHandler): string[] { + const calls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push(url); + return handler(url); + }) as typeof fetch; + return calls; +} + +function didDocResponse(endpoint: string): Response { + return Response.json({ + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: endpoint, + }, + ], + }); +} + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("isResolvableIdentifier", () => { + it("accepts handles and DIDs", () => { + expect(isResolvableIdentifier("alice.example.com")).toBe(true); + expect(isResolvableIdentifier("@alice.example.com")).toBe(true); + expect(isResolvableIdentifier("did:plc:abc123")).toBe(true); + }); + + it("rejects email addresses and bare names", () => { + expect(isResolvableIdentifier("alice@example.com")).toBe(false); + expect(isResolvableIdentifier("alice")).toBe(false); + }); +}); + +describe("resolveHandleToDid", () => { + it("resolves through the appview", async () => { + const calls = mockFetch(() => Response.json({ did: "did:plc:abc123" })); + + expect(await resolveHandleToDid("@Alice.Example.com")).toBe( + "did:plc:abc123", + ); + expect(calls[0]).toContain("handle=alice.example.com"); + }); + + it("returns DIDs untouched without any lookup", async () => { + const calls = mockFetch(() => { + throw new Error("should not fetch"); + }); + + expect(await resolveHandleToDid("did:plc:abc123")).toBe("did:plc:abc123"); + expect(calls).toHaveLength(0); + }); + + it("falls back to the handle's well-known document", async () => { + const calls = mockFetch((url) => + url.includes("public.api.bsky.app") + ? new Response("not found", { status: 400 }) + : new Response("did:web:alice.example.com\n"), + ); + + expect(await resolveHandleToDid("alice.example.com")).toBe( + "did:web:alice.example.com", + ); + expect(calls[1]).toBe("https://alice.example.com/.well-known/atproto-did"); + }); +}); + +describe("resolveHandleToPDS", () => { + it("reads the PDS endpoint from a did:plc document", async () => { + mockFetch((url) => + url.includes("plc.directory") + ? didDocResponse("https://pds.example.com") + : Response.json({ did: "did:plc:abc123" }), + ); + + expect(await resolveHandleToPDS("alice.example.com")).toBe( + "https://pds.example.com", + ); + }); + + it("reads the PDS endpoint from a did:web document", async () => { + const calls = mockFetch(() => didDocResponse("https://pds.example.com")); + + expect(await resolveHandleToPDS("did:web:alice.example.com")).toBe( + "https://pds.example.com", + ); + expect(calls[0]).toBe("https://alice.example.com/.well-known/did.json"); + }); + + it("throws when the DID document has no PDS service", async () => { + mockFetch((url) => + url.includes("plc.directory") + ? Response.json({ service: [] }) + : Response.json({ did: "did:plc:abc123" }), + ); + + expect(resolveHandleToPDS("alice.example.com")).rejects.toThrow( + "Could not find PDS URL for user", + ); + }); +}); + +describe("resolvePdsUrlOrDefault", () => { + it("returns the resolved PDS", async () => { + mockFetch((url) => + url.includes("plc.directory") + ? didDocResponse("https://pds.example.com") + : Response.json({ did: "did:plc:abc123" }), + ); + + expect(await resolvePdsUrlOrDefault("alice.example.com")).toBe( + "https://pds.example.com", + ); + }); + + it("falls back to the default PDS for an email identifier", async () => { + const calls = mockFetch(() => { + throw new Error("should not fetch"); + }); + + expect(await resolvePdsUrlOrDefault("alice@example.com")).toBe( + DEFAULT_PDS_URL, + ); + expect(calls).toHaveLength(0); + }); + + it("falls back to the default PDS when resolution fails", async () => { + mockFetch(() => new Response("nope", { status: 500 })); + + expect(await resolvePdsUrlOrDefault("alice.example.com")).toBe( + DEFAULT_PDS_URL, + ); + }); +});