From 76ba39f436816d50a82ce5cab3464d2bc6701e4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 16:15:35 +0000 Subject: [PATCH 1/9] feat: migrate CLIs to @clack/prompts alpha with built-in progress bar - Upgrade @clack/prompts from 0.11.0 to 1.0.0-alpha.9 in both packages - Replace custom progress bar implementation with clack's progress() API - Simplifies blob transfer progress tracking in migration command - Progress bar now uses native clack rendering with advance() method The alpha version provides a cleaner API for progress tracking: - progress({ max }) initialization - start(message) to begin - advance(count) to update progress - stop(message) to complete This removes ~10 lines of custom progress bar rendering code. --- packages/create-pds/package.json | 2 +- packages/pds/package.json | 2 +- packages/pds/src/cli/commands/migrate.ts | 26 +++++++------------ pnpm-lock.yaml | 32 ++++++++++++------------ 4 files changed, 27 insertions(+), 35 deletions(-) diff --git a/packages/create-pds/package.json b/packages/create-pds/package.json index b36ebba8..084c4c61 100644 --- a/packages/create-pds/package.json +++ b/packages/create-pds/package.json @@ -16,7 +16,7 @@ "check": "publint" }, "devDependencies": { - "@clack/prompts": "^0.11.0", + "@clack/prompts": "1.0.0-alpha.9", "@types/node": "^24.10.4", "citty": "^0.1.6", "publint": "^0.3.16", diff --git a/packages/pds/package.json b/packages/pds/package.json index 04ac2ad6..d9ee4ce4 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -32,7 +32,7 @@ "@atproto/lexicon": "^0.6.0", "@atproto/repo": "^0.8.12", "@atproto/syntax": "^0.4.2", - "@clack/prompts": "^0.11.0", + "@clack/prompts": "1.0.0-alpha.9", "bcryptjs": "^3.0.3", "citty": "^0.1.6", "hono": "^4.11.3", diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index 09cbf8a5..6f3a9da5 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -457,14 +457,6 @@ export const migrateCommand = defineCommand({ let cursor: string | undefined; let failedBlobs: string[] = []; - const progressBar = (current: number, total: number): string => { - const width = 20; - const ratio = total > 0 ? Math.min(1, current / total) : 0; - const filled = Math.round(ratio * width); - const empty = width - filled; - return `${"█".repeat(filled)}${"░".repeat(empty)} ${current}/${total}`; - }; - // First, count total missing blobs spinner.start("Counting images to transfer..."); let countCursor: string | undefined; @@ -474,7 +466,11 @@ export const migrateCommand = defineCommand({ countCursor = page.cursor; } while (countCursor); - spinner.message(`Transferring images ${progressBar(0, totalBlobs)}`); + spinner.stop(`Found ${formatNumber(totalBlobs)} images to transfer`); + + // Use clack progress bar for transferring + const progressBar = p.progress({ max: totalBlobs }); + progressBar.start("Transferring images"); do { const page = await targetClient.listMissingBlobs(100, cursor); @@ -488,26 +484,22 @@ export const migrateCommand = defineCommand({ ); await targetClient.uploadBlob(bytes, mimeType); synced++; - spinner.message( - `Transferring images ${progressBar(synced, totalBlobs)}`, - ); + progressBar.advance(1); } catch (err) { synced++; failedBlobs.push(blob.cid); - spinner.message( - `Transferring images ${progressBar(synced, totalBlobs)}`, - ); + progressBar.advance(1); } } } while (cursor); if (failedBlobs.length > 0) { - spinner.stop( + progressBar.stop( `Transferred ${formatNumber(synced - failedBlobs.length)} images (${failedBlobs.length} failed)`, ); p.log.warn(`Run 'pds migrate' again to retry failed transfers.`); } else { - spinner.stop(`Transferred ${formatNumber(synced)} images`); + progressBar.stop(`Transferred ${formatNumber(synced)} images`); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 846cf773..39d469d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ importers: packages/create-pds: devDependencies: '@clack/prompts': - specifier: ^0.11.0 - version: 0.11.0 + specifier: 1.0.0-alpha.9 + version: 1.0.0-alpha.9 '@types/node': specifier: ^24.10.4 version: 24.10.4 @@ -131,8 +131,8 @@ importers: specifier: ^0.4.2 version: 0.4.2 '@clack/prompts': - specifier: ^0.11.0 - version: 0.11.0 + specifier: 1.0.0-alpha.9 + version: 1.0.0-alpha.9 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -365,18 +365,18 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@clack/core@0.5.0': - resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + '@clack/core@1.0.0-alpha.7': + resolution: {integrity: sha512-3vdh6Ar09D14rVxJZIm3VQJkU+ZOKKT5I5cC0cOVazy70CNyYYjiwRj9unwalhESndgxx6bGc/m6Hhs4EKF5XQ==} - '@clack/prompts@0.11.0': - resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@clack/prompts@1.0.0-alpha.9': + resolution: {integrity: sha512-sKs0UjiHFWvry4SiRfBi5Qnj0C/6AYx8aKkFPZQSuUZXgAram25ZDmhQmP7vj1aFyLpfHWtLQjWvOvcat0TOLg==} '@cloudflare/kv-asset-handler@0.4.1': resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} engines: {node: '>=18.0.0'} '@cloudflare/kv-asset-handler@https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/kv-asset-handler@64982d4': - resolution: {tarball: https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/kv-asset-handler@64982d4} + resolution: {integrity: sha512-A1qPBLIb6J9iKy2M8X/ywh4e9jyHsAucJZVPNqCHdHrkeJIUntVfozazCZj9Sdm+rr4om20toCQgoxRPQtlWLQ==, tarball: https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/kv-asset-handler@64982d4} version: 0.4.1 engines: {node: '>=18.0.0'} @@ -390,7 +390,7 @@ packages: optional: true '@cloudflare/unenv-preset@https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@64982d4': - resolution: {tarball: https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@64982d4} + resolution: {integrity: sha512-WXbktS8e26BzZjbqQL2syj9UTC4+8zdEIbRQ8hsVxsAp6bES9q9lkyGnreX9Ax/2ENWpc0VFidwq/k3M9MNkqA==, tarball: https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@64982d4} version: 2.7.13 peerDependencies: unenv: 2.0.0-rc.24 @@ -406,7 +406,7 @@ packages: wrangler: ^4.53.0 '@cloudflare/vitest-pool-workers@https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632': - resolution: {tarball: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632} + resolution: {integrity: sha512-bUcBi9IflGaKQGFyxjyluNfZ4Wi+0jJzz2SMwWpHGqTPtfLJqfGv3VhfpecEE2Dir6Vohtn7Frs8uiIVThM9JA==, tarball: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632} version: 0.11.1 peerDependencies: '@vitest/runner': 4.0.16 @@ -1948,7 +1948,7 @@ packages: hasBin: true miniflare@https://pkg.pr.new/cloudflare/workers-sdk/miniflare@64982d4: - resolution: {tarball: https://pkg.pr.new/cloudflare/workers-sdk/miniflare@64982d4} + resolution: {integrity: sha512-HygCuVJoQbUVyHBGRtvF3LorrbqWavL1R1gXhYJqaenbPYuP2d//2zp3zmYPF2yRkaTzVg/NDqN9BlaU6/btoA==, tarball: https://pkg.pr.new/cloudflare/workers-sdk/miniflare@64982d4} version: 4.20251217.0 engines: {node: '>=18.0.0'} hasBin: true @@ -2613,7 +2613,7 @@ packages: optional: true wrangler@https://pkg.pr.new/cloudflare/workers-sdk/wrangler@64982d4: - resolution: {tarball: https://pkg.pr.new/cloudflare/workers-sdk/wrangler@64982d4} + resolution: {integrity: sha512-x/FjewjH8KL8s4w09157cJOPpY/ovh9fPJQ8VTFA6ncxsi1BZrk/N2aRZqh8l6M2x58z+w4J6VbDtgBkE7pXnA==, tarball: https://pkg.pr.new/cloudflare/workers-sdk/wrangler@64982d4} version: 4.56.0 engines: {node: '>=20.0.0'} hasBin: true @@ -3020,14 +3020,14 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@clack/core@0.5.0': + '@clack/core@1.0.0-alpha.7': dependencies: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/prompts@0.11.0': + '@clack/prompts@1.0.0-alpha.9': dependencies: - '@clack/core': 0.5.0 + '@clack/core': 1.0.0-alpha.7 picocolors: 1.1.1 sisteransi: 1.0.5 From f997a2a4db7ce4439b5900f9a52b7ff0d72a7639 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 18:22:53 +0000 Subject: [PATCH 2/9] feat: enhance CLI UX with taskLog, box(), and spinner.error() Replace custom info box hacks with alpha's native box() prompt: - Remove brightNote() ANSI escape workarounds - Use box() with titleAlign for centered titles - Consistent formatting across all CLIs Add taskLog for transient setup steps: - Secret generation in `pds init` (clears on success) - Project setup in `create-pds` (template copy, git init) - Reduces visual noise while maintaining reassurance Use spinner.error() for failure states: - Replaces spinner.stop() on errors across all commands - Clearer semantic distinction between success/failure - Better visual feedback with error icons Goals: reassurance during operations, delight with polished UX --- packages/create-pds/src/index.ts | 14 +++--- packages/pds/src/cli/commands/activate.ts | 4 +- packages/pds/src/cli/commands/deactivate.ts | 16 +++--- packages/pds/src/cli/commands/init.ts | 26 +++++----- packages/pds/src/cli/commands/migrate.ts | 54 ++++++++++----------- 5 files changed, 53 insertions(+), 61 deletions(-) diff --git a/packages/create-pds/src/index.ts b/packages/create-pds/src/index.ts index b59e35c8..e26e2b7f 100644 --- a/packages/create-pds/src/index.ts +++ b/packages/create-pds/src/index.ts @@ -245,8 +245,9 @@ const main = defineCommand({ const pdsVersion = await getLatestPdsVersion(); spinner.stop(`Using @ascorbic/pds ${pdsVersion}`); - spinner.start("Copying template..."); + const setupTask = p.taskLog({ title: "Setting up project" }); + setupTask.message("Copying template files..."); const templateDir = join(__dirname, "..", "templates", "pds-worker"); await copyTemplateDir(templateDir, targetDir); @@ -256,19 +257,18 @@ const main = defineCommand({ pdsVersion, }); - spinner.stop("Template copied"); - // Initialize git if (initGit) { - spinner.start("Initializing git..."); + setupTask.message("Initializing git repository..."); try { await runCommand("git", ["init"], targetDir, { silent: true }); - spinner.stop("Git initialized"); } catch { - spinner.stop("Failed to initialize git"); + setupTask.error("Failed to initialize git"); } } + setupTask.success("Project created"); + // Install dependencies if (!args["skip-install"]) { spinner.start(`Installing dependencies with ${pm}...`); @@ -276,7 +276,7 @@ const main = defineCommand({ await runCommand(pm, ["install"], targetDir, { silent: true }); spinner.stop("Dependencies installed"); } catch { - spinner.stop("Failed to install dependencies"); + spinner.error("Failed to install dependencies"); p.log.warning("You can install dependencies manually later"); } } diff --git a/packages/pds/src/cli/commands/activate.ts b/packages/pds/src/cli/commands/activate.ts index 583d01ae..a3992b00 100644 --- a/packages/pds/src/cli/commands/activate.ts +++ b/packages/pds/src/cli/commands/activate.ts @@ -86,7 +86,7 @@ export const activateCommand = defineCommand({ } // Show confirmation - p.note( + p.box( [ `@${handle || "your-handle"}`, "", @@ -114,7 +114,7 @@ export const activateCommand = defineCommand({ await client.activateAccount(); spinner.stop("Account activated!"); } catch (err) { - spinner.stop("Activation failed"); + spinner.error("Activation failed"); p.log.error( err instanceof Error ? err.message : "Could not activate account", ); diff --git a/packages/pds/src/cli/commands/deactivate.ts b/packages/pds/src/cli/commands/deactivate.ts index 287e981a..f9f175db 100644 --- a/packages/pds/src/cli/commands/deactivate.ts +++ b/packages/pds/src/cli/commands/deactivate.ts @@ -9,10 +9,6 @@ import { readDevVars } from "../utils/dotenv.js"; import { PDSClient } from "../utils/pds-client.js"; import { getTargetUrl, getDomain } from "../utils/cli-helpers.js"; -// Helper to override clack's dim styling in notes -const brightNote = (lines: string[]) => lines.map((l) => `\x1b[0m${l}`).join("\n"); -const bold = (text: string) => pc.bold(text); - export const deactivateCommand = defineCommand({ meta: { name: "deactivate", @@ -91,17 +87,17 @@ export const deactivateCommand = defineCommand({ } // Show warning - p.note( - brightNote([ - bold(`⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`), + p.box( + [ + pc.bold(`⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`), "", "Your account will:", " • Stop accepting new posts, follows, and other writes", " • Remain readable in the Atmosphere", " • Allow you to use 'pds migrate --clean' to re-import", "", - bold("Only deactivate if you need to re-import your data."), - ]), + pc.bold("Only deactivate if you need to re-import your data."), + ].join("\n"), "Deactivate Account", ); @@ -121,7 +117,7 @@ export const deactivateCommand = defineCommand({ await client.deactivateAccount(); spinner.stop("Account deactivated"); } catch (err) { - spinner.stop("Deactivation failed"); + spinner.error("Deactivation failed"); p.log.error( err instanceof Error ? err.message : "Could not deactivate account", ); diff --git a/packages/pds/src/cli/commands/init.ts b/packages/pds/src/cli/commands/init.ts index 50e0605b..4041db6d 100644 --- a/packages/pds/src/cli/commands/init.ts +++ b/packages/pds/src/cli/commands/init.ts @@ -364,7 +364,7 @@ export const initCommand = defineCommand({ // Show different notes based on whether handle matches hostname if (handle === hostname) { - p.note( + p.box( [ "Your handle matches your PDS hostname, so your PDS will", "automatically handle domain verification for you!", @@ -380,7 +380,7 @@ export const initCommand = defineCommand({ "Identity Setup 🪪", ); } else { - p.note( + p.box( [ "For did:web, your PDS will serve the DID document at:", ` https://${hostname}/.well-known/did.json`, @@ -452,23 +452,23 @@ export const initCommand = defineCommand({ // Local mode: always prompt for password and generate fresh secrets const password = await promptPassword(handle); - spinner.start("Hashing password..."); + const setupTask = p.taskLog({ title: "Setting up secrets" }); + + setupTask.message("Hashing password..."); passwordHash = await hashPassword(password); - spinner.stop("Password hashed"); - spinner.start("Generating JWT secret..."); + setupTask.message("Generating JWT secret..."); jwtSecret = generateJwtSecret(); - spinner.stop("JWT secret generated"); - spinner.start("Generating auth token..."); + setupTask.message("Generating auth token..."); authToken = generateAuthToken(); - spinner.stop("Auth token generated"); - spinner.start("Generating signing keypair..."); + setupTask.message("Generating signing keypair..."); const keypair = await generateSigningKeypair(); signingKey = keypair.privateKey; signingKeyPublic = keypair.publicKey; - spinner.stop("Signing keypair generated"); + + setupTask.success("Secrets generated"); } // Always set public vars and worker name in wrangler.jsonc @@ -506,10 +506,10 @@ export const initCommand = defineCommand({ await runWranglerTypes(); spinner.stop("TypeScript types generated"); } catch { - spinner.stop("Failed to generate types (wrangler types)"); + spinner.error("Failed to generate types (wrangler types)"); } - p.note( + p.box( [ " Worker name: " + workerName, " PDS hostname: " + hostname, @@ -547,7 +547,7 @@ export const initCommand = defineCommand({ } if (isMigrating) { - p.note( + p.box( [ deployedSecrets ? "Deploy your worker and run the migration:" diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index 6f3a9da5..bb5e7e62 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -22,11 +22,6 @@ function detectPackageManager(): PackageManager { return "npm"; } -// Helper to override clack's dim styling in notes -const brightNote = (lines: string[]) => - lines.map((l) => `\x1b[0m${l}`).join("\n"); -const bold = (text: string) => pc.bold(text); - /** * Format number with commas */ @@ -89,7 +84,7 @@ export const migrateCommand = defineCommand({ const isHealthy = await targetClient.healthCheck(); if (!isHealthy) { - spinner.stop(`PDS not responding at ${targetDomain}`); + spinner.error(`PDS not responding at ${targetDomain}`); if (isDev) { p.log.error(`Your local PDS isn't running at ${targetUrl}`); p.log.info(`Start it with: ${pm} dev`); @@ -138,7 +133,7 @@ export const migrateCommand = defineCommand({ const didDoc = await didResolver.resolve(did); if (!didDoc) { - spinner.stop("Failed to resolve DID"); + spinner.error("Failed to resolve DID"); p.log.error(`Could not resolve DID: ${did}`); p.outro("Migration cancelled."); process.exit(1); @@ -164,7 +159,7 @@ export const migrateCommand = defineCommand({ try { status = await targetClient.getAccountStatus(); } catch (err) { - spinner.stop("Failed to get account status"); + spinner.error("Failed to get account status"); p.log.error( err instanceof Error ? err.message : "Could not get account status", ); @@ -190,17 +185,17 @@ export const migrateCommand = defineCommand({ } // Show what will be deleted - p.note( - brightNote([ - bold("This will permanently delete from your new PDS:"), + p.box( + [ + pc.bold("This will permanently delete from your new PDS:"), "", ` • ${formatNumber(status.repoBlocks)} repository blocks`, ` • ${formatNumber(status.importedBlobs)} imported images`, " • All blob tracking data", "", - bold(`Your data on ${sourceDomain} is NOT affected.`), + pc.bold(`Your data on ${sourceDomain} is NOT affected.`), "You'll need to re-import everything.", - ]), + ].join("\n"), "⚠️ Reset Migration Data", ); @@ -221,7 +216,7 @@ export const migrateCommand = defineCommand({ `Deleted ${formatNumber(result.blocksDeleted)} blocks, ${formatNumber(result.blobsCleared)} blobs`, ); } catch (err) { - spinner.stop("Reset failed"); + spinner.error("Reset failed"); p.log.error( err instanceof Error ? err.message : "Could not reset migration", ); @@ -254,7 +249,7 @@ export const migrateCommand = defineCommand({ try { await sourceClient.describeRepo(did); } catch (err) { - spinner.stop("Failed to fetch account details"); + spinner.error("Failed to fetch account details"); p.log.error( err instanceof Error ? err.message @@ -286,7 +281,7 @@ export const migrateCommand = defineCommand({ "Looks like you started packing earlier. Let's pick up where we left off.", ); - p.note( + p.box( [ `@${handle} (${did.slice(0, 20)}...)`, "", @@ -320,17 +315,18 @@ export const migrateCommand = defineCommand({ ] : [` 📝 Posts, follows, images, likes, and blocks`]; - p.note( - brightNote([ - bold(`@${handle}`) + ` (${did.slice(0, 20)}...)`, + p.box( + [ + pc.bold(`@${handle}`) + ` (${did.slice(0, 20)}...)`, "", `Currently at: ${sourceDomain}`, `Moving to: ${targetDomain}`, "", "What you're bringing:", ...statsLines, - ]), + ].join("\n"), "Your Bluesky Account 🦋", + { titleAlign: "center" }, ); p.log.info( @@ -378,7 +374,7 @@ export const migrateCommand = defineCommand({ sourceClient.setAuthToken(session.accessJwt); spinner.stop("Authenticated successfully"); } catch (err) { - spinner.stop("Login failed"); + spinner.error("Login failed"); if (err instanceof PDSClientError) { p.log.error(`Authentication failed: ${err.message}`); } else { @@ -402,7 +398,7 @@ export const migrateCommand = defineCommand({ `Downloaded ${formatBytes(carBytes.length)} from ${sourceDomain}`, ); } catch (err) { - spinner.stop("Export failed"); + spinner.error("Export failed"); p.log.error( err instanceof Error ? err.message : "Could not export repository", ); @@ -415,7 +411,7 @@ export const migrateCommand = defineCommand({ await targetClient.importRepo(carBytes); spinner.stop("Repository imported"); } catch (err) { - spinner.stop("Import failed"); + spinner.error("Import failed"); p.log.error( err instanceof Error ? err.message : "Could not import repository", ); @@ -528,20 +524,20 @@ export const migrateCommand = defineCommand({ }); function showNextSteps(pm: string, sourceDomain: string): void { - p.note( - brightNote([ - bold("Your data is safe in your new PDS."), + p.box( + [ + pc.bold("Your data is safe in your new PDS."), "Two more steps to go live in the Atmosphere:", "", - bold("1. Update your identity"), + pc.bold("1. Update your identity"), " Tell the network where you live now.", ` (Requires email verification from ${sourceDomain})`, "", - bold("2. Flip the switch"), + pc.bold("2. Flip the switch"), ` ${pm} pds activate`, "", "Docs: https://atproto.com/guides/account-migration", - ]), + ].join("\n"), "Almost there!", ); } From 65f2bc536c2fbd72b03a9b7268a54735f0f297ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 18:29:51 +0000 Subject: [PATCH 3/9] chore: add changeset for clack alpha migration --- .changeset/migrate-clack-alpha.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/migrate-clack-alpha.md diff --git a/.changeset/migrate-clack-alpha.md b/.changeset/migrate-clack-alpha.md new file mode 100644 index 00000000..c9024890 --- /dev/null +++ b/.changeset/migrate-clack-alpha.md @@ -0,0 +1,12 @@ +--- +"@ascorbic/pds": minor +"create-pds": minor +--- + +Upgrade CLIs to @clack/prompts alpha with enhanced UX features + +- **Progress bar**: Replace custom progress rendering with clack's built-in `progress()` API for blob transfers +- **Info boxes**: Replace ANSI escape hacks with native `box()` prompt supporting title alignment and better formatting +- **Task logging**: Add `taskLog()` for transient setup steps that clear on success (secret generation, project setup) +- **Error states**: Use `spinner.error()` for failure cases providing clearer visual distinction +- **Polished UX**: Focus on reassurance during operations and delight with clean, professional output From 42d29e800fa417b5ce8d6ab6f4f3a65529c2eace Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 19:36:51 +0000 Subject: [PATCH 4/9] feat: add color to 'Your New Home' setup box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add vibrant colors to the PDS setup completion box: - Cyan labels for configuration items - Bold handle and auth token for emphasis - Dimmed DIDs and keys for less critical info - Green checkmark for success states - Yellow highlight for important auth token - Blue link for migration docs Makes the setup completion more delightful and easier to scan! ✨ --- packages/pds/src/cli/commands/init.ts | 38 +++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/pds/src/cli/commands/init.ts b/packages/pds/src/cli/commands/init.ts index 4041db6d..e3911d4c 100644 --- a/packages/pds/src/cli/commands/init.ts +++ b/packages/pds/src/cli/commands/init.ts @@ -511,18 +511,18 @@ export const initCommand = defineCommand({ p.box( [ - " Worker name: " + workerName, - " PDS hostname: " + hostname, - " DID: " + did, - " Handle: " + handle, - " Public signing key: " + signingKeyPublic.slice(0, 20) + "...", + pc.cyan(" Worker name: ") + workerName, + pc.cyan(" PDS hostname: ") + hostname, + pc.cyan(" DID: ") + pc.dim(did), + pc.cyan(" Handle: ") + pc.bold(handle), + pc.cyan(" Public signing key: ") + pc.dim(signingKeyPublic.slice(0, 20) + "..."), "", isProduction - ? "Secrets deployed to Cloudflare ☁️" - : "Secrets saved to .dev.vars", + ? pc.green("✓ Secrets deployed to Cloudflare ☁️") + : pc.green("✓ Secrets saved to .dev.vars"), "", - "Auth token (save this!):", - " " + authToken, + pc.yellow("Auth token (save this!):"), + " " + pc.bold(authToken), ].join("\n"), "Your New Home 🏠", ); @@ -550,19 +550,19 @@ export const initCommand = defineCommand({ p.box( [ deployedSecrets - ? "Deploy your worker and run the migration:" - : "Push secrets, deploy, and run the migration:", + ? pc.bold("Deploy your worker and run the migration:") + : pc.bold("Push secrets, deploy, and run the migration:"), "", - ...(deployedSecrets ? [] : [" pnpm pds init --production", ""]), - " wrangler deploy", - " pnpm pds migrate", + ...(deployedSecrets ? [] : [pc.cyan(" pnpm pds init --production"), ""]), + pc.cyan(" wrangler deploy"), + pc.cyan(" pnpm pds migrate"), "", - "To test locally first:", - " pnpm dev # in one terminal", - " pnpm pds migrate --dev # in another", + pc.bold("To test locally first:"), + pc.cyan(" pnpm dev") + pc.dim(" # in one terminal"), + pc.cyan(" pnpm pds migrate --dev") + pc.dim(" # in another"), "", - "Then update your identity and flip the switch! 🦋", - " https://atproto.com/guides/account-migration", + pc.bold("Then update your identity and flip the switch! 🦋"), + pc.blue(" https://atproto.com/guides/account-migration"), ].join("\n"), "Next Steps 🧳", ); From 4e6a633abc7aa55d5d97377bb4bc92d7a0470922 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 19:38:47 +0000 Subject: [PATCH 5/9] fix: show actual progress count in image transfer The progress bar was showing a spinner without progress info. Now displays '123/456 images transferred' as it works, with failure count when errors occur. Fixes the 'just spinning' issue during blob migration. --- packages/pds/src/cli/commands/migrate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index dbb9cd91..8a15ac26 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -443,11 +443,11 @@ export const migrateCommand = defineCommand({ ); await targetClient.uploadBlob(bytes, mimeType); synced++; - progressBar.advance(1); + progressBar.advance(1, `${synced}/${totalBlobs} images transferred`); } catch (err) { synced++; failedBlobs.push(blob.cid); - progressBar.advance(1); + progressBar.advance(1, `${synced}/${totalBlobs} images (${failedBlobs.length} failed)`); } } } while (cursor); From 90f2a2a5319bd4ab046e6090744b4e52a4f811f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 19:41:07 +0000 Subject: [PATCH 6/9] feat: configure progress bar to use block style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set progress bar to use 'block' style (█) instead of line style. Also set width to 30 characters for better visibility. Available styles: 'light' (─), 'heavy' (━), 'block' (█) --- packages/pds/src/cli/commands/migrate.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index 8a15ac26..d30edb5e 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -428,7 +428,11 @@ export const migrateCommand = defineCommand({ spinner.stop(`Found ${formatNumber(totalBlobs)} images to transfer`); // Use clack progress bar for transferring - const progressBar = p.progress({ max: totalBlobs }); + const progressBar = p.progress({ + max: totalBlobs, + style: 'block', + size: 30 + }); progressBar.start("Transferring images"); do { From bfba5a32f53ab1dbf5b369c0099b4a480f08f2fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 19:46:31 +0000 Subject: [PATCH 7/9] feat: add colors throughout CLI commands for improved UX Enhance visual hierarchy and user experience by adding picocolors styling: - Cyan for domains and technical identifiers (targetDomain, sourceDomain) - Green for success states (authenticated, imported, activated) - Yellow for warnings and important counts - Bold for handles and important identifiers - Consistent use of spinner.error() for failure states This complements the previous UX improvements (box, taskLog, progress bar) and adds visual delight throughout the migration, activation, and deactivation workflows. --- packages/pds/src/cli/commands/activate.ts | 9 ++++---- packages/pds/src/cli/commands/deactivate.ts | 14 ++++++------ packages/pds/src/cli/commands/migrate.ts | 24 ++++++++++----------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/pds/src/cli/commands/activate.ts b/packages/pds/src/cli/commands/activate.ts index a3992b00..8d2f3136 100644 --- a/packages/pds/src/cli/commands/activate.ts +++ b/packages/pds/src/cli/commands/activate.ts @@ -3,6 +3,7 @@ */ import { defineCommand } from "citty"; import * as p from "@clack/prompts"; +import pc from "picocolors"; import { getVars } from "../utils/wrangler.js"; import { readDevVars } from "../utils/dotenv.js"; import { PDSClient } from "../utils/pds-client.js"; @@ -57,7 +58,7 @@ export const activateCommand = defineCommand({ // Check if PDS is reachable const spinner = p.spinner(); - spinner.start(`Checking PDS at ${targetDomain}...`); + spinner.start(`Checking PDS at ${pc.cyan(targetDomain)}...`); const isHealthy = await client.healthCheck(); if (!isHealthy) { @@ -70,7 +71,7 @@ export const activateCommand = defineCommand({ process.exit(1); } - spinner.stop(`Connected to ${targetDomain}`); + spinner.stop(`Connected to ${pc.cyan(targetDomain)}`); // Get current account status spinner.start("Checking account status..."); @@ -88,7 +89,7 @@ export const activateCommand = defineCommand({ // Show confirmation p.box( [ - `@${handle || "your-handle"}`, + pc.bold(`@${handle || "your-handle"}`), "", "This will enable writes and make your account live.", "Make sure you've:", @@ -112,7 +113,7 @@ export const activateCommand = defineCommand({ spinner.start("Activating account..."); try { await client.activateAccount(); - spinner.stop("Account activated!"); + spinner.stop(pc.green("Account activated! 🎉")); } catch (err) { spinner.error("Activation failed"); p.log.error( diff --git a/packages/pds/src/cli/commands/deactivate.ts b/packages/pds/src/cli/commands/deactivate.ts index f9f175db..f3a8075b 100644 --- a/packages/pds/src/cli/commands/deactivate.ts +++ b/packages/pds/src/cli/commands/deactivate.ts @@ -58,11 +58,11 @@ export const deactivateCommand = defineCommand({ // Check if PDS is reachable const spinner = p.spinner(); - spinner.start(`Checking PDS at ${targetDomain}...`); + spinner.start(`Checking PDS at ${pc.cyan(targetDomain)}...`); const isHealthy = await client.healthCheck(); if (!isHealthy) { - spinner.stop(`PDS not responding at ${targetDomain}`); + spinner.error(`PDS not responding at ${targetDomain}`); p.log.error(`Your PDS isn't responding at ${targetUrl}`); if (!isDev) { p.log.info("Make sure your worker is deployed: wrangler deploy"); @@ -71,7 +71,7 @@ export const deactivateCommand = defineCommand({ process.exit(1); } - spinner.stop(`Connected to ${targetDomain}`); + spinner.stop(`Connected to ${pc.cyan(targetDomain)}`); // Get current account status spinner.start("Checking account status..."); @@ -89,7 +89,7 @@ export const deactivateCommand = defineCommand({ // Show warning p.box( [ - pc.bold(`⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`), + pc.yellow(pc.bold(`⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`)), "", "Your account will:", " • Stop accepting new posts, follows, and other writes", @@ -115,7 +115,7 @@ export const deactivateCommand = defineCommand({ spinner.start("Deactivating account..."); try { await client.deactivateAccount(); - spinner.stop("Account deactivated"); + spinner.stop(pc.green("Account deactivated")); } catch (err) { spinner.error("Deactivation failed"); p.log.error( @@ -129,10 +129,10 @@ export const deactivateCommand = defineCommand({ p.log.info("Writes are now disabled."); p.log.info(""); p.log.info("To re-import your data:"); - p.log.info(" pnpm pds migrate --clean"); + p.log.info(` ${pc.cyan("pnpm pds migrate --clean")}`); p.log.info(""); p.log.info("To re-enable writes:"); - p.log.info(" pnpm pds activate"); + p.log.info(` ${pc.cyan("pnpm pds activate")}`); p.outro("Deactivated."); }, }); diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index d30edb5e..22d5ac58 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -75,13 +75,13 @@ export const migrateCommand = defineCommand({ p.intro("🦋 PDS Migration"); const spinner = p.spinner(); - spinner.start(`Checking PDS at ${targetDomain}...`); + spinner.start(`Checking PDS at ${pc.cyan(targetDomain)}...`); const targetClient = new PDSClient(targetUrl); const isHealthy = await targetClient.healthCheck(); if (!isHealthy) { - spinner.error(`PDS not responding at ${targetDomain}`); + spinner.error(`PDS not responding at ${pc.cyan(targetDomain)}`); if (isDev) { p.log.error(`Your local PDS isn't running at ${targetUrl}`); p.log.info(`Start it with: ${pm} dev`); @@ -93,7 +93,7 @@ export const migrateCommand = defineCommand({ p.outro("Migration cancelled."); process.exit(1); } - spinner.stop(`Connected to ${targetDomain}`); + spinner.stop(`Connected to ${pc.cyan(targetDomain)}`); const wranglerVars = getVars(); const devVars = readDevVars(); @@ -117,7 +117,7 @@ export const migrateCommand = defineCommand({ targetClient.setAuthToken(authToken); - spinner.start(`Looking up @${handle}...`); + spinner.start(`Looking up ${pc.bold(`@${handle}`)}...`); const didResolver = new DidResolver(); const didDoc = await didResolver.resolve(did); @@ -138,7 +138,7 @@ export const migrateCommand = defineCommand({ } const sourceDomain = getDomain(sourcePdsUrl); - spinner.stop(`Found your account at ${sourceDomain}`); + spinner.stop(`Found your account at ${pc.cyan(sourceDomain)}`); spinner.start("Checking account status..."); @@ -221,7 +221,7 @@ export const migrateCommand = defineCommand({ return; } - spinner.start(`Fetching your account details from ${sourceDomain}...`); + spinner.start(`Fetching your account details from ${pc.cyan(sourceDomain)}...`); const sourceClient = new PDSClient(sourcePdsUrl); try { @@ -339,12 +339,12 @@ export const migrateCommand = defineCommand({ } spinner.start( - `Logging in to ${isBlueskyPds ? "Bluesky" : sourceDomain}...`, + `Logging in to ${isBlueskyPds ? pc.cyan("Bluesky") : pc.cyan(sourceDomain)}...`, ); try { const session = await sourceClient.createSession(did, password); sourceClient.setAuthToken(session.accessJwt); - spinner.stop("Authenticated successfully"); + spinner.stop(pc.green("Authenticated successfully")); } catch (err) { spinner.error("Login failed"); if (err instanceof PDSClientError) { @@ -364,7 +364,7 @@ export const migrateCommand = defineCommand({ try { carBytes = await sourceClient.getRepo(did); spinner.stop( - `Downloaded ${formatBytes(carBytes.length)} from ${sourceDomain}`, + `Downloaded ${pc.green(formatBytes(carBytes.length))} from ${pc.cyan(sourceDomain)}`, ); } catch (err) { spinner.error("Export failed"); @@ -375,10 +375,10 @@ export const migrateCommand = defineCommand({ process.exit(1); } - spinner.start(`Unpacking at ${targetDomain}...`); + spinner.start(`Unpacking at ${pc.cyan(targetDomain)}...`); try { await targetClient.importRepo(carBytes); - spinner.stop("Repository imported"); + spinner.stop(pc.green("Repository imported")); } catch (err) { spinner.error("Import failed"); p.log.error( @@ -425,7 +425,7 @@ export const migrateCommand = defineCommand({ countCursor = page.cursor; } while (countCursor); - spinner.stop(`Found ${formatNumber(totalBlobs)} images to transfer`); + spinner.stop(`Found ${pc.yellow(formatNumber(totalBlobs))} images to transfer`); // Use clack progress bar for transferring const progressBar = p.progress({ From be3aa3227dff0031fcbfc3ddbee487d5611ee5ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 19:48:51 +0000 Subject: [PATCH 8/9] fix: use heavy style for progress bar for better visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'heavy' style (━) is more visible than 'block' (█) with the default magenta color used by @clack/prompts progress bar. --- packages/pds/src/cli/commands/migrate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index 22d5ac58..06b242a1 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -430,7 +430,7 @@ export const migrateCommand = defineCommand({ // Use clack progress bar for transferring const progressBar = p.progress({ max: totalBlobs, - style: 'block', + style: 'heavy', size: 30 }); progressBar.start("Transferring images"); @@ -447,7 +447,7 @@ export const migrateCommand = defineCommand({ ); await targetClient.uploadBlob(bytes, mimeType); synced++; - progressBar.advance(1, `${synced}/${totalBlobs} images transferred`); + progressBar.advance(1, `${pc.green(formatNumber(synced))}/${formatNumber(totalBlobs)} images transferred`); } catch (err) { synced++; failedBlobs.push(blob.cid); From a6d57079ce1ce7d9d8a768e9126c2181a6a4bbff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Jan 2026 19:55:01 +0000 Subject: [PATCH 9/9] revert: change progress bar back to block style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block style (█) is more visible than heavy style (━) in most terminals. Also includes Prettier formatting fixes (tabs instead of spaces). --- packages/oauth-provider/README.md | 116 +++++++------- .../oauth-provider/src/client-resolver.ts | 29 ++-- packages/oauth-provider/src/dpop.ts | 47 ++++-- packages/oauth-provider/src/index.ts | 17 +- packages/oauth-provider/src/par.ts | 39 +++-- packages/oauth-provider/src/pkce.ts | 2 +- packages/oauth-provider/src/provider.ts | 147 ++++++++++++++---- packages/oauth-provider/src/tokens.ts | 12 +- packages/oauth-provider/src/ui.ts | 17 +- packages/oauth-provider/test/dpop.test.ts | 44 ++++-- packages/oauth-provider/test/helpers.ts | 27 +++- .../oauth-provider/test/oauth-flow.test.ts | 37 +++-- packages/oauth-provider/test/par.test.ts | 23 ++- packages/oauth-provider/test/pkce.test.ts | 20 ++- packages/oauth-provider/tsconfig.json | 11 +- packages/pds/e2e/export.e2e.ts | 9 +- packages/pds/e2e/firehose.e2e.ts | 9 +- packages/pds/e2e/helpers.ts | 4 +- packages/pds/e2e/session.e2e.ts | 7 +- packages/pds/e2e/setup.ts | 4 +- packages/pds/src/account-do.ts | 5 +- packages/pds/src/cli/commands/activate.ts | 2 +- packages/pds/src/cli/commands/deactivate.ts | 6 +- packages/pds/src/cli/commands/init.ts | 7 +- packages/pds/src/cli/commands/migrate.ts | 26 +++- packages/pds/src/cli/utils/cli-helpers.ts | 5 +- packages/pds/src/cli/utils/pds-client.ts | 15 +- packages/pds/src/oauth.ts | 5 +- packages/pds/src/storage.ts | 2 +- packages/pds/test/migration.test.ts | 82 ++++++---- packages/pds/test/oauth.test.ts | 12 +- plans/complete/core-pds.md | 18 +++ plans/complete/oauth-provider.md | 43 ++--- plans/todo/endpoint-implementation.md | 128 +++++++-------- plans/todo/migration-wizard.md | 64 ++++---- 35 files changed, 667 insertions(+), 374 deletions(-) diff --git a/packages/oauth-provider/README.md b/packages/oauth-provider/README.md index 4b3be0a3..bca31b92 100644 --- a/packages/oauth-provider/README.md +++ b/packages/oauth-provider/README.md @@ -29,28 +29,28 @@ import { OAuthStorage } from "./your-storage-implementation"; // Initialize the provider const provider = new OAuthProvider({ - issuer: "https://your-pds.example.com", - storage: new OAuthStorage(), + issuer: "https://your-pds.example.com", + storage: new OAuthStorage(), }); // Handle OAuth endpoints in your Worker app.post("/oauth/par", async (c) => { - const result = await provider.handlePAR(await c.req.formData()); - return c.json(result); + const result = await provider.handlePAR(await c.req.formData()); + return c.json(result); }); app.get("/oauth/authorize", async (c) => { - const result = await provider.handleAuthorize(c.req.url); - // Show authorization UI to user - return c.html(renderAuthUI(result)); + const result = await provider.handleAuthorize(c.req.url); + // Show authorization UI to user + return c.html(renderAuthUI(result)); }); app.post("/oauth/token", async (c) => { - const result = await provider.handleToken( - await c.req.formData(), - c.req.header("DPoP"), - ); - return c.json(result); + const result = await provider.handleToken( + await c.req.formData(), + c.req.header("DPoP"), + ); + return c.json(result); }); ``` @@ -72,29 +72,29 @@ The provider uses a storage interface that you implement for your backend: ```typescript export interface OAuthProviderStorage { - // Authorization codes - saveAuthCode(code: string, data: AuthCodeData): Promise; - getAuthCode(code: string): Promise; - deleteAuthCode(code: string): Promise; - - // Access/refresh tokens - saveTokens(data: TokenData): Promise; - getTokenByAccess(accessToken: string): Promise; - getTokenByRefresh(refreshToken: string): Promise; - revokeToken(accessToken: string): Promise; - revokeAllTokens(sub: string): Promise; - - // Client metadata cache - saveClient(clientId: string, metadata: ClientMetadata): Promise; - getClient(clientId: string): Promise; - - // PAR (Pushed Authorization Requests) - savePAR(requestUri: string, data: PARData): Promise; - getPAR(requestUri: string): Promise; - deletePAR(requestUri: string): Promise; - - // DPoP nonce tracking - checkAndSaveNonce(nonce: string): Promise; + // Authorization codes + saveAuthCode(code: string, data: AuthCodeData): Promise; + getAuthCode(code: string): Promise; + deleteAuthCode(code: string): Promise; + + // Access/refresh tokens + saveTokens(data: TokenData): Promise; + getTokenByAccess(accessToken: string): Promise; + getTokenByRefresh(refreshToken: string): Promise; + revokeToken(accessToken: string): Promise; + revokeAllTokens(sub: string): Promise; + + // Client metadata cache + saveClient(clientId: string, metadata: ClientMetadata): Promise; + getClient(clientId: string): Promise; + + // PAR (Pushed Authorization Requests) + savePAR(requestUri: string, data: PARData): Promise; + getPAR(requestUri: string): Promise; + deletePAR(requestUri: string): Promise; + + // DPoP nonce tracking + checkAndSaveNonce(nonce: string): Promise; } ``` @@ -122,8 +122,8 @@ Response: ```json { - "request_uri": "urn:ietf:params:oauth:request_uri:XXXXXX", - "expires_in": 90 + "request_uri": "urn:ietf:params:oauth:request_uri:XXXXXX", + "expires_in": 90 } ``` @@ -162,12 +162,12 @@ Response: ```json { - "access_token": "XXXXXX", - "token_type": "DPoP", - "expires_in": 3600, - "refresh_token": "YYYYYY", - "scope": "atproto", - "sub": "did:plc:abc123" + "access_token": "XXXXXX", + "token_type": "DPoP", + "expires_in": 3600, + "refresh_token": "YYYYYY", + "scope": "atproto", + "sub": "did:plc:abc123" } ``` @@ -202,14 +202,14 @@ Clients are identified by a URL pointing to their metadata document: ```json { - "client_id": "https://client.example.com/client-metadata.json", - "client_name": "Example App", - "redirect_uris": ["https://client.example.com/callback"], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "scope": "atproto", - "token_endpoint_auth_method": "none", - "application_type": "web" + "client_id": "https://client.example.com/client-metadata.json", + "client_name": "Example App", + "redirect_uris": ["https://client.example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "atproto", + "token_endpoint_auth_method": "none", + "application_type": "web" } ``` @@ -224,15 +224,15 @@ This provider is designed to work seamlessly with `@atproto/oauth-client`: import { OAuthClient } from "@atproto/oauth-client"; const client = new OAuthClient({ - clientMetadata: { - client_id: "https://my-app.example.com/client-metadata.json", - redirect_uris: ["https://my-app.example.com/callback"], - }, + clientMetadata: { + client_id: "https://my-app.example.com/client-metadata.json", + redirect_uris: ["https://my-app.example.com/callback"], + }, }); // Initiate login const authUrl = await client.authorize("https://user-pds.example.com", { - scope: "atproto", + scope: "atproto", }); // Handle callback @@ -245,8 +245,8 @@ The provider returns standard OAuth 2.1 error responses: ```json { - "error": "invalid_request", - "error_description": "Missing required parameter: code_challenge" + "error": "invalid_request", + "error_description": "Missing required parameter: code_challenge" } ``` diff --git a/packages/oauth-provider/src/client-resolver.ts b/packages/oauth-provider/src/client-resolver.ts index 7b9984af..e95f2861 100644 --- a/packages/oauth-provider/src/client-resolver.ts +++ b/packages/oauth-provider/src/client-resolver.ts @@ -18,7 +18,7 @@ export type { OAuthClientMetadata }; export class ClientResolutionError extends Error { constructor( message: string, - public readonly code: string + public readonly code: string, ) { super(message); this.name = "ClientResolutionError"; @@ -110,13 +110,17 @@ export class ClientResolver { if (!isHttpsUrl(clientId) && !isValidDid(clientId)) { throw new ClientResolutionError( `Invalid client ID format: ${clientId}`, - "invalid_client" + "invalid_client", ); } if (this.storage) { const cached = await this.storage.getClient(clientId); - if (cached && cached.cachedAt && Date.now() - cached.cachedAt < this.cacheTtl) { + if ( + cached && + cached.cachedAt && + Date.now() - cached.cachedAt < this.cacheTtl + ) { return cached; } } @@ -125,7 +129,7 @@ export class ClientResolver { if (!metadataUrl) { throw new ClientResolutionError( `Unsupported client ID format: ${clientId}`, - "invalid_client" + "invalid_client", ); } @@ -139,14 +143,14 @@ export class ClientResolver { } catch (e) { throw new ClientResolutionError( `Failed to fetch client metadata: ${e}`, - "invalid_client" + "invalid_client", ); } if (!response.ok) { throw new ClientResolutionError( `Client metadata fetch failed with status ${response.status}`, - "invalid_client" + "invalid_client", ); } @@ -157,14 +161,14 @@ export class ClientResolver { } catch (e) { throw new ClientResolutionError( `Invalid client metadata: ${e instanceof Error ? e.message : "validation failed"}`, - "invalid_client" + "invalid_client", ); } if (doc.client_id !== clientId) { throw new ClientResolutionError( `Client ID mismatch: expected ${clientId}, got ${doc.client_id}`, - "invalid_client" + "invalid_client", ); } @@ -190,7 +194,10 @@ export class ClientResolver { * @param redirectUri The redirect URI to validate * @returns true if the redirect URI is allowed */ - async validateRedirectUri(clientId: string, redirectUri: string): Promise { + async validateRedirectUri( + clientId: string, + redirectUri: string, + ): Promise { try { const metadata = await this.resolveClient(clientId); return metadata.redirectUris.includes(redirectUri); @@ -203,6 +210,8 @@ export class ClientResolver { /** * Create a client resolver with optional caching */ -export function createClientResolver(options: ClientResolverOptions = {}): ClientResolver { +export function createClientResolver( + options: ClientResolverOptions = {}, +): ClientResolver { return new ClientResolver(options); } diff --git a/packages/oauth-provider/src/dpop.ts b/packages/oauth-provider/src/dpop.ts index 638e11b5..7d171cb6 100644 --- a/packages/oauth-provider/src/dpop.ts +++ b/packages/oauth-provider/src/dpop.ts @@ -3,7 +3,13 @@ * Implements RFC 9449 using jose library for JWT operations */ -import { jwtVerify, EmbeddedJWK, calculateJwkThumbprint, errors, base64url } from "jose"; +import { + jwtVerify, + EmbeddedJWK, + calculateJwkThumbprint, + errors, + base64url, +} from "jose"; import type { JWK } from "jose"; import { randomString } from "./encoding.js"; @@ -73,7 +79,10 @@ function parseHtu(htu: string): string { } if (url.password || url.username) { - throw new DpopError('DPoP "htu" must not contain credentials', "invalid_dpop"); + throw new DpopError( + 'DPoP "htu" must not contain credentials', + "invalid_dpop", + ); } if (url.protocol !== "http:" && url.protocol !== "https:") { @@ -93,9 +102,14 @@ function parseHtu(htu: string): string { */ export async function verifyDpopProof( request: Request, - options: DpopVerifyOptions = {} + options: DpopVerifyOptions = {}, ): Promise { - const { allowedAlgorithms = ["ES256"], accessToken, expectedNonce, maxTokenAge = 60 } = options; + const { + allowedAlgorithms = ["ES256"], + accessToken, + expectedNonce, + maxTokenAge = 60, + } = options; const dpopHeader = request.headers.get("DPoP"); if (!dpopHeader) { @@ -123,9 +137,15 @@ export async function verifyDpopProof( payload = result.payload as typeof payload; } catch (err) { if (err instanceof JOSEError) { - throw new DpopError(`DPoP verification failed: ${err.message}`, "invalid_dpop", { cause: err }); + throw new DpopError( + `DPoP verification failed: ${err.message}`, + "invalid_dpop", + { cause: err }, + ); } - throw new DpopError("DPoP verification failed", "invalid_dpop", { cause: err }); + throw new DpopError("DPoP verification failed", "invalid_dpop", { + cause: err, + }); } if (!payload.jti || typeof payload.jti !== "string") { @@ -158,17 +178,26 @@ export async function verifyDpopProof( // Verify ath (access token hash) binding per RFC 9449 Section 4.3 if (accessToken) { if (!payload.ath) { - throw new DpopError('DPoP "ath" missing when access token provided', "invalid_dpop"); + throw new DpopError( + 'DPoP "ath" missing when access token provided', + "invalid_dpop", + ); } - const tokenHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)); + const tokenHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ); const expectedAth = base64url.encode(new Uint8Array(tokenHash)); if (payload.ath !== expectedAth) { throw new DpopError('DPoP "ath" mismatch', "invalid_dpop"); } } else if (payload.ath !== undefined) { - throw new DpopError('DPoP "ath" claim not allowed without access token', "invalid_dpop"); + throw new DpopError( + 'DPoP "ath" claim not allowed without access token', + "invalid_dpop", + ); } const jwk = protectedHeader.jwk!; diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index abc2ed83..49569205 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -4,7 +4,11 @@ */ // Core provider -export { ATProtoOAuthProvider, parseRequestBody, RequestBodyError } from "./provider.js"; +export { + ATProtoOAuthProvider, + parseRequestBody, + RequestBodyError, +} from "./provider.js"; export type { OAuthProviderConfig } from "./provider.js"; // Storage interface and types @@ -29,8 +33,15 @@ export { PARHandler } from "./par.js"; export type { OAuthParResponse, OAuthErrorResponse } from "./par.js"; // Client resolution -export { ClientResolver, createClientResolver, ClientResolutionError } from "./client-resolver.js"; -export type { ClientResolverOptions, OAuthClientMetadata } from "./client-resolver.js"; +export { + ClientResolver, + createClientResolver, + ClientResolutionError, +} from "./client-resolver.js"; +export type { + ClientResolverOptions, + OAuthClientMetadata, +} from "./client-resolver.js"; // Tokens export { diff --git a/packages/oauth-provider/src/par.ts b/packages/oauth-provider/src/par.ts index 22ffc642..46ac0f78 100644 --- a/packages/oauth-provider/src/par.ts +++ b/packages/oauth-provider/src/par.ts @@ -34,7 +34,14 @@ function generateRequestUri(): string { /** * Required OAuth parameters for authorization request */ -const REQUIRED_PARAMS = ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state"]; +const REQUIRED_PARAMS = [ + "client_id", + "redirect_uri", + "response_type", + "code_challenge", + "code_challenge_method", + "state", +]; /** * Handler for Pushed Authorization Requests (PAR) @@ -50,7 +57,11 @@ export class PARHandler { * @param issuer The OAuth issuer URL * @param expiresIn PAR expiration time in seconds (default: 90) */ - constructor(storage: OAuthStorage, issuer: string, expiresIn: number = DEFAULT_EXPIRES_IN) { + constructor( + storage: OAuthStorage, + issuer: string, + expiresIn: number = DEFAULT_EXPIRES_IN, + ) { this.storage = storage; this.issuer = issuer; this.expiresIn = expiresIn; @@ -70,18 +81,26 @@ export class PARHandler { return this.errorResponse( "invalid_request", e instanceof Error ? e.message : "Invalid request", - 400 + 400, ); } const clientId = params.client_id; if (!clientId) { - return this.errorResponse("invalid_request", "Missing client_id parameter", 400); + return this.errorResponse( + "invalid_request", + "Missing client_id parameter", + 400, + ); } for (const param of REQUIRED_PARAMS) { if (!params[param]) { - return this.errorResponse("invalid_request", `Missing required parameter: ${param}`, 400); + return this.errorResponse( + "invalid_request", + `Missing required parameter: ${param}`, + 400, + ); } } @@ -89,7 +108,7 @@ export class PARHandler { return this.errorResponse( "unsupported_response_type", "Only response_type=code is supported", - 400 + 400, ); } @@ -97,7 +116,7 @@ export class PARHandler { return this.errorResponse( "invalid_request", "Only code_challenge_method=S256 is supported", - 400 + 400, ); } @@ -106,7 +125,7 @@ export class PARHandler { return this.errorResponse( "invalid_request", "Invalid code_challenge format", - 400 + 400, ); } @@ -150,7 +169,7 @@ export class PARHandler { */ async retrieveParams( requestUri: string, - clientId: string + clientId: string, ): Promise | null> { if (!requestUri.startsWith(REQUEST_URI_PREFIX)) { return null; @@ -184,7 +203,7 @@ export class PARHandler { private errorResponse( error: string, description: string, - status: number = 400 + status: number = 400, ): Response { const body: OAuthErrorResponse = { error, diff --git a/packages/oauth-provider/src/pkce.ts b/packages/oauth-provider/src/pkce.ts index 2b6ea130..1b0adbf7 100644 --- a/packages/oauth-provider/src/pkce.ts +++ b/packages/oauth-provider/src/pkce.ts @@ -26,7 +26,7 @@ async function generateCodeChallenge(verifier: string): Promise { export async function verifyPkceChallenge( verifier: string, challenge: string, - method: "S256" + method: "S256", ): Promise { if (method !== "S256") { throw new Error("Only S256 challenge method is supported"); diff --git a/packages/oauth-provider/src/provider.ts b/packages/oauth-provider/src/provider.ts index d6aa6d76..2ce49725 100644 --- a/packages/oauth-provider/src/provider.ts +++ b/packages/oauth-provider/src/provider.ts @@ -4,7 +4,12 @@ */ import type { OAuthAuthorizationServerMetadata } from "@atproto/oauth-types"; -import type { OAuthStorage, AuthCodeData, TokenData, ClientMetadata } from "./storage.js"; +import type { + OAuthStorage, + AuthCodeData, + TokenData, + ClientMetadata, +} from "./storage.js"; import { verifyPkceChallenge } from "./pkce.js"; import { verifyDpopProof, DpopError, generateDpopNonce } from "./dpop.js"; import { PARHandler } from "./par.js"; @@ -35,7 +40,9 @@ export interface OAuthProviderConfig { /** Client resolver for DID-based discovery */ clientResolver?: ClientResolver; /** Callback to verify user credentials */ - verifyUser?: (password: string) => Promise<{ sub: string; handle: string } | null>; + verifyUser?: ( + password: string, + ) => Promise<{ sub: string; handle: string } | null>; /** Get the current user (if already authenticated) */ getCurrentUser?: () => Promise<{ sub: string; handle: string } | null>; } @@ -43,7 +50,11 @@ export interface OAuthProviderConfig { /** * OAuth error response builder */ -function oauthError(error: string, description: string, status: number = 400): Response { +function oauthError( + error: string, + description: string, + status: number = 400, +): Response { return new Response( JSON.stringify({ error, @@ -55,7 +66,7 @@ function oauthError(error: string, description: string, status: number = 400): R "Content-Type": "application/json", "Cache-Control": "no-store", }, - } + }, ); } @@ -73,21 +84,26 @@ export class RequestBodyError extends Error { * Parse request body from JSON or form-urlencoded * @throws RequestBodyError if content type is unsupported or parsing fails */ -export async function parseRequestBody(request: Request): Promise> { +export async function parseRequestBody( + request: Request, +): Promise> { const contentType = request.headers.get("Content-Type") ?? ""; try { if (contentType.includes("application/json")) { const json = await request.json(); return Object.fromEntries( - Object.entries(json as Record).map(([k, v]) => [k, String(v)]) + Object.entries(json as Record).map(([k, v]) => [ + k, + String(v), + ]), ); } else if (contentType.includes("application/x-www-form-urlencoded")) { const body = await request.text(); return Object.fromEntries(new URLSearchParams(body).entries()); } else { throw new RequestBodyError( - "Content-Type must be application/json or application/x-www-form-urlencoded" + "Content-Type must be application/json or application/x-www-form-urlencoded", ); } } catch (e) { @@ -108,8 +124,13 @@ export class ATProtoOAuthProvider { private enablePAR: boolean; private parHandler: PARHandler; private clientResolver: ClientResolver; - private verifyUser?: (password: string) => Promise<{ sub: string; handle: string } | null>; - private getCurrentUser?: () => Promise<{ sub: string; handle: string } | null>; + private verifyUser?: ( + password: string, + ) => Promise<{ sub: string; handle: string } | null>; + private getCurrentUser?: () => Promise<{ + sub: string; + handle: string; + } | null>; constructor(config: OAuthProviderConfig) { this.storage = config.storage; @@ -117,7 +138,8 @@ export class ATProtoOAuthProvider { this.dpopRequired = config.dpopRequired ?? true; this.enablePAR = config.enablePAR ?? true; this.parHandler = new PARHandler(config.storage, config.issuer); - this.clientResolver = config.clientResolver ?? new ClientResolver({ storage: config.storage }); + this.clientResolver = + config.clientResolver ?? new ClientResolver({ storage: config.storage }); this.verifyUser = config.verifyUser; this.getCurrentUser = config.getCurrentUser; } @@ -147,11 +169,20 @@ export class ATProtoOAuthProvider { if (requestUri && this.enablePAR) { if (!clientId) { - return this.renderError("invalid_request", "client_id required with request_uri"); + return this.renderError( + "invalid_request", + "client_id required with request_uri", + ); } - const parParams = await this.parHandler.retrieveParams(requestUri, clientId); + const parParams = await this.parHandler.retrieveParams( + requestUri, + clientId, + ); if (!parParams) { - return this.renderError("invalid_request", "Invalid or expired request_uri"); + return this.renderError( + "invalid_request", + "Invalid or expired request_uri", + ); } params = parParams; } else { @@ -161,21 +192,39 @@ export class ATProtoOAuthProvider { } // Validate required parameters - const required = ["client_id", "redirect_uri", "response_type", "code_challenge", "state"]; + const required = [ + "client_id", + "redirect_uri", + "response_type", + "code_challenge", + "state", + ]; for (const param of required) { if (!params[param]) { - return this.renderError("invalid_request", `Missing required parameter: ${param}`); + return this.renderError( + "invalid_request", + `Missing required parameter: ${param}`, + ); } } // Validate response_type if (params.response_type !== "code") { - return this.renderError("unsupported_response_type", "Only response_type=code is supported"); + return this.renderError( + "unsupported_response_type", + "Only response_type=code is supported", + ); } // Validate code_challenge_method - if (params.code_challenge_method && params.code_challenge_method !== "S256") { - return this.renderError("invalid_request", "Only code_challenge_method=S256 is supported"); + if ( + params.code_challenge_method && + params.code_challenge_method !== "S256" + ) { + return this.renderError( + "invalid_request", + "Only code_challenge_method=S256 is supported", + ); } // Resolve client metadata @@ -183,12 +232,18 @@ export class ATProtoOAuthProvider { try { client = await this.clientResolver.resolveClient(params.client_id!); } catch (e) { - return this.renderError("invalid_client", `Failed to resolve client: ${e}`); + return this.renderError( + "invalid_client", + `Failed to resolve client: ${e}`, + ); } // Validate redirect_uri if (!client.redirectUris.includes(params.redirect_uri!)) { - return this.renderError("invalid_request", "Invalid redirect_uri for this client"); + return this.renderError( + "invalid_request", + "Invalid redirect_uri for this client", + ); } // Handle POST (form submission) @@ -230,7 +285,7 @@ export class ATProtoOAuthProvider { private async handleAuthorizePost( request: Request, params: Record, - client: ClientMetadata + client: ClientMetadata, ): Promise { // Form data was already parsed in handleAuthorize - extract action and password const action = params.action; @@ -253,7 +308,10 @@ export class ATProtoOAuthProvider { errorUrl.hash = hashParams.toString(); } else { errorUrl.searchParams.set("error", "access_denied"); - errorUrl.searchParams.set("error_description", "User denied authorization"); + errorUrl.searchParams.set( + "error_description", + "User denied authorization", + ); errorUrl.searchParams.set("state", state); errorUrl.searchParams.set("iss", this.issuer); } @@ -339,7 +397,10 @@ export class ATProtoOAuthProvider { try { params = await parseRequestBody(request); } catch (e) { - return oauthError("invalid_request", e instanceof Error ? e.message : "Invalid request"); + return oauthError( + "invalid_request", + e instanceof Error ? e.message : "Invalid request", + ); } const grantType = params.grant_type; @@ -349,7 +410,10 @@ export class ATProtoOAuthProvider { } else if (grantType === "refresh_token") { return this.handleRefreshTokenGrant(request, params); } else { - return oauthError("unsupported_grant_type", `Unsupported grant_type: ${grantType}`); + return oauthError( + "unsupported_grant_type", + `Unsupported grant_type: ${grantType}`, + ); } } @@ -358,20 +422,26 @@ export class ATProtoOAuthProvider { */ private async handleAuthorizationCodeGrant( request: Request, - params: Record + params: Record, ): Promise { // Validate required parameters const required = ["code", "client_id", "redirect_uri", "code_verifier"]; for (const param of required) { if (!params[param]) { - return oauthError("invalid_request", `Missing required parameter: ${param}`); + return oauthError( + "invalid_request", + `Missing required parameter: ${param}`, + ); } } // Get authorization code data const codeData = await this.storage.getAuthCode(params.code!); if (!codeData) { - return oauthError("invalid_grant", "Invalid or expired authorization code"); + return oauthError( + "invalid_grant", + "Invalid or expired authorization code", + ); } // Delete code (one-time use) @@ -391,7 +461,7 @@ export class ATProtoOAuthProvider { const pkceValid = await verifyPkceChallenge( params.code_verifier!, codeData.codeChallenge, - codeData.codeChallengeMethod + codeData.codeChallengeMethod, ); if (!pkceValid) { return oauthError("invalid_grant", "Invalid code_verifier"); @@ -427,7 +497,7 @@ export class ATProtoOAuthProvider { "DPoP-Nonce": nonce, "Cache-Control": "no-store", }, - } + }, ); } return oauthError("invalid_dpop_proof", e.message); @@ -440,9 +510,14 @@ export class ATProtoOAuthProvider { if (dpopHeader) { try { const dpopProof = await verifyDpopProof(request); - const nonceUnique = await this.storage.checkAndSaveNonce(dpopProof.jti); + const nonceUnique = await this.storage.checkAndSaveNonce( + dpopProof.jti, + ); if (!nonceUnique) { - return oauthError("invalid_dpop_proof", "DPoP proof replay detected"); + return oauthError( + "invalid_dpop_proof", + "DPoP proof replay detected", + ); } dpopJkt = dpopProof.jkt; } catch (e) { @@ -480,7 +555,7 @@ export class ATProtoOAuthProvider { */ private async handleRefreshTokenGrant( request: Request, - params: Record + params: Record, ): Promise { const refreshToken = params.refresh_token; if (!refreshToken) { @@ -569,7 +644,11 @@ export class ATProtoOAuthProvider { grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], - scopes_supported: ["atproto", "transition:generic", "transition:chat.bsky"], + scopes_supported: [ + "atproto", + "transition:generic", + "transition:chat.bsky", + ], subject_types_supported: ["public"], authorization_response_iss_parameter_supported: true, client_id_metadata_document_supported: true, @@ -600,7 +679,7 @@ export class ATProtoOAuthProvider { */ async verifyAccessToken( request: Request, - requiredScope?: string + requiredScope?: string, ): Promise { // Extract token from Authorization header const tokenInfo = extractAccessToken(request); diff --git a/packages/oauth-provider/src/tokens.ts b/packages/oauth-provider/src/tokens.ts index 02139237..baf7c37c 100644 --- a/packages/oauth-provider/src/tokens.ts +++ b/packages/oauth-provider/src/tokens.ts @@ -125,13 +125,15 @@ export function generateTokens(options: GenerateTokensOptions): { export function refreshTokens( existingData: TokenData, rotateRefreshToken: boolean = false, - accessTokenTtl: number = ACCESS_TOKEN_TTL + accessTokenTtl: number = ACCESS_TOKEN_TTL, ): { tokens: GeneratedTokens; tokenData: TokenData; } { const accessToken = generateRandomToken(32); - const refreshToken = rotateRefreshToken ? generateRandomToken(32) : existingData.refreshToken; + const refreshToken = rotateRefreshToken + ? generateRandomToken(32) + : existingData.refreshToken; const now = Date.now(); const tokenData: TokenData = { @@ -159,7 +161,9 @@ export function refreshTokens( * @param tokens The generated tokens * @returns JSON-serializable token response */ -export function buildTokenResponse(tokens: GeneratedTokens): OAuthTokenResponse { +export function buildTokenResponse( + tokens: GeneratedTokens, +): OAuthTokenResponse { return { access_token: tokens.accessToken, token_type: tokens.tokenType, @@ -177,7 +181,7 @@ export function buildTokenResponse(tokens: GeneratedTokens): OAuthTokenResponse * @returns The access token and type, or null if not found */ export function extractAccessToken( - request: Request + request: Request, ): { token: string; type: "Bearer" | "DPoP" } | null { const authHeader = request.headers.get("Authorization"); if (!authHeader) { diff --git a/packages/oauth-provider/src/ui.ts b/packages/oauth-provider/src/ui.ts index 0d8a6fff..2060dd8b 100644 --- a/packages/oauth-provider/src/ui.ts +++ b/packages/oauth-provider/src/ui.ts @@ -90,7 +90,15 @@ export interface ConsentUIOptions { * @returns HTML string */ export function renderConsentUI(options: ConsentUIOptions): string { - const { client, scope, authorizeUrl, oauthParams, userHandle, showLogin, error } = options; + const { + client, + scope, + authorizeUrl, + oauthParams, + userHandle, + showLogin, + error, + } = options; const clientName = escapeHtml(client.clientName); const scopeDescriptions = getScopeDescriptions(scope); @@ -113,7 +121,10 @@ export function renderConsentUI(options: ConsentUIOptions): string { // Render OAuth params as hidden form fields const hiddenFieldsHtml = Object.entries(oauthParams) - .map(([key, value]) => ``) + .map( + ([key, value]) => + ``, + ) .join("\n\t\t\t"); return ` @@ -368,7 +379,7 @@ export function renderConsentUI(options: ConsentUIOptions): string { export function renderErrorPage( error: string, description: string, - redirectUri?: string + redirectUri?: string, ): string { const escapedError = escapeHtml(error); const escapedDescription = escapeHtml(description); diff --git a/packages/oauth-provider/test/dpop.test.ts b/packages/oauth-provider/test/dpop.test.ts index e5a84955..2c5e74d5 100644 --- a/packages/oauth-provider/test/dpop.test.ts +++ b/packages/oauth-provider/test/dpop.test.ts @@ -4,7 +4,11 @@ import { verifyDpopProof, generateDpopNonce, DpopError } from "../src/dpop.js"; import { createDpopProof, generateDpopKeyPair } from "./helpers.js"; describe("DPoP", () => { - let keyPair: { privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }; + let keyPair: { + privateKey: CryptoKey; + publicKey: CryptoKey; + publicJwk: JsonWebKey; + }; beforeEach(async () => { keyPair = await generateDpopKeyPair("ES256"); @@ -58,14 +62,16 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://example.com/token" }, - "ES256" + "ES256", ); expect(proof).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); // Parse and verify header const [headerB64] = proof.split("."); - const header = JSON.parse(atob(headerB64!.replace(/-/g, "+").replace(/_/g, "/"))); + const header = JSON.parse( + atob(headerB64!.replace(/-/g, "+").replace(/_/g, "/")), + ); expect(header.typ).toBe("dpop+jwt"); expect(header.alg).toBe("ES256"); expect(header.jwk).toBeDefined(); @@ -75,9 +81,11 @@ describe("DPoP", () => { const accessToken = "test-access-token"; const tokenHash = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(accessToken) + new TextEncoder().encode(accessToken), ); - const expectedAth = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) + const expectedAth = btoa( + String.fromCharCode(...new Uint8Array(tokenHash)), + ) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); @@ -86,11 +94,13 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "GET", htu: "https://example.com/api", ath: expectedAth }, - "ES256" + "ES256", ); const [, payloadB64] = proof.split("."); - const payload = JSON.parse(atob(payloadB64!.replace(/-/g, "+").replace(/_/g, "/"))); + const payload = JSON.parse( + atob(payloadB64!.replace(/-/g, "+").replace(/_/g, "/")), + ); expect(payload.ath).toBe(expectedAth); }); }); @@ -101,7 +111,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://example.com/token" }, - "ES256" + "ES256", ); const request = new Request("https://example.com/token", { @@ -140,7 +150,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://example.com/token" }, - "ES256" + "ES256", ); const request = new Request("https://example.com/token", { @@ -156,7 +166,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://example.com/token" }, - "ES256" + "ES256", ); const request = new Request("https://other.com/token", { @@ -172,7 +182,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://example.com/token" }, - "ES256" + "ES256", ); const request = new Request("https://example.com/token?foo=bar", { @@ -188,7 +198,7 @@ describe("DPoP", () => { const accessToken = "test-access-token"; const tokenHash = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(accessToken) + new TextEncoder().encode(accessToken), ); const ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) .replace(/\+/g, "-") @@ -199,7 +209,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "GET", htu: "https://example.com/api", ath }, - "ES256" + "ES256", ); const request = new Request("https://example.com/api", { @@ -221,7 +231,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "GET", htu: "https://example.com/api", ath }, - "ES256" + "ES256", ); const request = new Request("https://example.com/api", { @@ -230,7 +240,7 @@ describe("DPoP", () => { }); await expect( - verifyDpopProof(request, { accessToken: "different-token" }) + verifyDpopProof(request, { accessToken: "different-token" }), ).rejects.toThrow(DpopError); }); @@ -239,7 +249,7 @@ describe("DPoP", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://example.com/token" }, - "ES256" + "ES256", ); const request = new Request("https://example.com/token", { @@ -248,7 +258,7 @@ describe("DPoP", () => { }); await expect( - verifyDpopProof(request, { allowedAlgorithms: ["RS256"] }) + verifyDpopProof(request, { allowedAlgorithms: ["RS256"] }), ).rejects.toThrow(DpopError); }); }); diff --git a/packages/oauth-provider/test/helpers.ts b/packages/oauth-provider/test/helpers.ts index b7f42af6..5764497b 100644 --- a/packages/oauth-provider/test/helpers.ts +++ b/packages/oauth-provider/test/helpers.ts @@ -40,7 +40,7 @@ export async function createDpopProof( privateKey: CryptoKey, publicJwk: JsonWebKey, claims: { htm: string; htu: string; ath?: string; nonce?: string }, - alg: string = "ES256" + alg: string = "ES256", ): Promise { const header = { typ: "dpop+jwt", @@ -57,8 +57,12 @@ export async function createDpopProof( ...(claims.nonce && { nonce: claims.nonce }), }; - const headerB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(header))); - const payloadB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(payload))); + const headerB64 = base64url.encode( + new TextEncoder().encode(JSON.stringify(header)), + ); + const payloadB64 = base64url.encode( + new TextEncoder().encode(JSON.stringify(payload)), + ); const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); const params = getAlgorithmParams(alg); @@ -67,7 +71,9 @@ export async function createDpopProof( } const signParams = - params.name === "ECDSA" ? { name: params.name, hash: params.hash } : { name: params.name }; + params.name === "ECDSA" + ? { name: params.name, hash: params.hash } + : { name: params.name }; const signature = await crypto.subtle.sign(signParams, privateKey, data); const signatureB64 = base64url.encode(new Uint8Array(signature)); @@ -81,8 +87,12 @@ export async function createDpopProof( * @returns The key pair and public JWK */ export async function generateDpopKeyPair( - alg: string = "ES256" -): Promise<{ privateKey: CryptoKey; publicKey: CryptoKey; publicJwk: JsonWebKey }> { + alg: string = "ES256", +): Promise<{ + privateKey: CryptoKey; + publicKey: CryptoKey; + publicJwk: JsonWebKey; +}> { const params = getAlgorithmParams(alg); if (!params) { throw new Error(`Unsupported algorithm: ${alg}`); @@ -103,7 +113,10 @@ export async function generateDpopKeyPair( "verify", ])) as CryptoKeyPair; - const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey; + const publicJwk = (await crypto.subtle.exportKey( + "jwk", + keyPair.publicKey, + )) as JsonWebKey; // Remove optional fields that shouldn't be in the proof delete publicJwk.key_ops; diff --git a/packages/oauth-provider/test/oauth-flow.test.ts b/packages/oauth-provider/test/oauth-flow.test.ts index c9b61519..573df984 100644 --- a/packages/oauth-provider/test/oauth-flow.test.ts +++ b/packages/oauth-provider/test/oauth-flow.test.ts @@ -148,7 +148,7 @@ describe("OAuth Flow", () => { describe("Token Endpoint", () => { async function getAuthCode( - verifier: string + verifier: string, ): Promise<{ code: string; challenge: string }> { const challenge = await generateCodeChallenge(verifier); @@ -187,7 +187,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const body = new URLSearchParams({ @@ -231,7 +231,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const body = new URLSearchParams({ @@ -268,7 +268,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const body = new URLSearchParams({ @@ -296,7 +296,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const request2 = new Request("https://pds.example.com/oauth/token", { @@ -322,7 +322,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const body1 = new URLSearchParams({ @@ -350,7 +350,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const body2 = new URLSearchParams({ @@ -370,7 +370,10 @@ describe("OAuth Flow", () => { const response2 = await provider.handleToken(request2); expect(response2.status).toBe(200); - const json2 = (await response2.json()) as { access_token: string; refresh_token: string }; + const json2 = (await response2.json()) as { + access_token: string; + refresh_token: string; + }; expect(json2.access_token).toBeDefined(); expect(json2.refresh_token).toBeDefined(); // Refresh token should be rotated @@ -385,10 +388,12 @@ describe("OAuth Flow", () => { const json = (await response.json()) as Record; expect(json.issuer).toBe("https://pds.example.com"); - expect(json.authorization_endpoint).toBe("https://pds.example.com/oauth/authorize"); + expect(json.authorization_endpoint).toBe( + "https://pds.example.com/oauth/authorize", + ); expect(json.token_endpoint).toBe("https://pds.example.com/oauth/token"); expect(json.pushed_authorization_request_endpoint).toBe( - "https://pds.example.com/oauth/par" + "https://pds.example.com/oauth/par", ); expect(json.response_types_supported).toContain("code"); expect(json.code_challenge_methods_supported).toContain("S256"); @@ -428,7 +433,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const tokenBody = new URLSearchParams({ @@ -454,7 +459,7 @@ describe("OAuth Flow", () => { // Compute access token hash for DPoP proof const tokenHash = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(tokens.access_token) + new TextEncoder().encode(tokens.access_token), ); const ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) .replace(/\+/g, "-") @@ -466,7 +471,7 @@ describe("OAuth Flow", () => { keyPair.privateKey, keyPair.publicJwk, { htm: "GET", htu: "https://pds.example.com/api/resource", ath }, - "ES256" + "ES256", ); const apiRequest = new Request("https://pds.example.com/api/resource", { @@ -514,7 +519,7 @@ describe("OAuth Flow", () => { keyPair1.privateKey, keyPair1.publicJwk, { htm: "POST", htu: "https://pds.example.com/oauth/token" }, - "ES256" + "ES256", ); const tokenBody = new URLSearchParams({ @@ -542,7 +547,7 @@ describe("OAuth Flow", () => { const tokenHash = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(tokens.access_token) + new TextEncoder().encode(tokens.access_token), ); const ath = btoa(String.fromCharCode(...new Uint8Array(tokenHash))) .replace(/\+/g, "-") @@ -553,7 +558,7 @@ describe("OAuth Flow", () => { keyPair2.privateKey, keyPair2.publicJwk, { htm: "GET", htu: "https://pds.example.com/api/resource", ath }, - "ES256" + "ES256", ); const apiRequest = new Request("https://pds.example.com/api/resource", { diff --git a/packages/oauth-provider/test/par.test.ts b/packages/oauth-provider/test/par.test.ts index 0971c2df..f6bd69c6 100644 --- a/packages/oauth-provider/test/par.test.ts +++ b/packages/oauth-provider/test/par.test.ts @@ -136,7 +136,10 @@ describe("PAR Handler", () => { const pushResponse = await handler.handlePushRequest(request); const pushJson = (await pushResponse.json()) as { request_uri: string }; - const params = await handler.retrieveParams(pushJson.request_uri, clientId); + const params = await handler.retrieveParams( + pushJson.request_uri, + clientId, + ); expect(params).not.toBeNull(); expect(params!.client_id).toBe(clientId); expect(params!.code_challenge).toBe(challenge); @@ -145,7 +148,7 @@ describe("PAR Handler", () => { it("returns null for non-existent request_uri", async () => { const params = await handler.retrieveParams( "urn:ietf:params:oauth:request_uri:nonexistent", - "did:web:client.example.com" + "did:web:client.example.com", ); expect(params).toBeNull(); }); @@ -168,7 +171,7 @@ describe("PAR Handler", () => { const params = await handler.retrieveParams( pushJson.request_uri, - "did:web:other.example.com" + "did:web:other.example.com", ); expect(params).toBeNull(); }); @@ -191,18 +194,26 @@ describe("PAR Handler", () => { const pushJson = (await pushResponse.json()) as { request_uri: string }; // First retrieval should work - const params1 = await handler.retrieveParams(pushJson.request_uri, clientId); + const params1 = await handler.retrieveParams( + pushJson.request_uri, + clientId, + ); expect(params1).not.toBeNull(); // Second retrieval should return null - const params2 = await handler.retrieveParams(pushJson.request_uri, clientId); + const params2 = await handler.retrieveParams( + pushJson.request_uri, + clientId, + ); expect(params2).toBeNull(); }); }); describe("isRequestUri", () => { it("returns true for valid request_uri format", () => { - expect(PARHandler.isRequestUri("urn:ietf:params:oauth:request_uri:abc123")).toBe(true); + expect( + PARHandler.isRequestUri("urn:ietf:params:oauth:request_uri:abc123"), + ).toBe(true); }); it("returns false for invalid format", () => { diff --git a/packages/oauth-provider/test/pkce.test.ts b/packages/oauth-provider/test/pkce.test.ts index 03cca0c9..5416a363 100644 --- a/packages/oauth-provider/test/pkce.test.ts +++ b/packages/oauth-provider/test/pkce.test.ts @@ -50,7 +50,11 @@ describe("PKCE", () => { it("rejects invalid verifier", async () => { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); - const result = await verifyPkceChallenge("wrong-verifier-value", challenge, "S256"); + const result = await verifyPkceChallenge( + "wrong-verifier-value", + challenge, + "S256", + ); expect(result).toBe(false); }); @@ -61,20 +65,28 @@ describe("PKCE", () => { it("rejects verifier that is too long", async () => { const longVerifier = "a".repeat(129); - const result = await verifyPkceChallenge(longVerifier, "challenge", "S256"); + const result = await verifyPkceChallenge( + longVerifier, + "challenge", + "S256", + ); expect(result).toBe(false); }); it("rejects verifier with invalid characters", async () => { const invalidVerifier = "a".repeat(43) + "!"; const challenge = await generateCodeChallenge("a".repeat(43)); - const result = await verifyPkceChallenge(invalidVerifier, challenge, "S256"); + const result = await verifyPkceChallenge( + invalidVerifier, + challenge, + "S256", + ); expect(result).toBe(false); }); it("throws for unsupported challenge method", async () => { await expect( - verifyPkceChallenge("verifier", "challenge", "plain" as "S256") + verifyPkceChallenge("verifier", "challenge", "plain" as "S256"), ).rejects.toThrow("Only S256 challenge method is supported"); }); }); diff --git a/packages/oauth-provider/tsconfig.json b/packages/oauth-provider/tsconfig.json index 95e6c1ba..e8ae1dd7 100644 --- a/packages/oauth-provider/tsconfig.json +++ b/packages/oauth-provider/tsconfig.json @@ -2,12 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", - "types": [ - "tsdown/client", - "@cloudflare/workers-types" - ] + "types": ["tsdown/client", "@cloudflare/workers-types"] }, - "include": [ - "src" - ] -} \ No newline at end of file + "include": ["src"] +} diff --git a/packages/pds/e2e/export.e2e.ts b/packages/pds/e2e/export.e2e.ts index 8d0b2a8d..d4eee9f7 100644 --- a/packages/pds/e2e/export.e2e.ts +++ b/packages/pds/e2e/export.e2e.ts @@ -1,7 +1,14 @@ import { describe, it, expect, beforeAll } from "vitest"; import { AtpAgent } from "@atproto/api"; import { CarReader } from "@ipld/car"; -import { createAgent, getBaseUrl, TEST_DID, TEST_HANDLE, TEST_PASSWORD, uniqueRkey } from "./helpers"; +import { + createAgent, + getBaseUrl, + TEST_DID, + TEST_HANDLE, + TEST_PASSWORD, + uniqueRkey, +} from "./helpers"; describe("CAR Export", () => { let agent: AtpAgent; diff --git a/packages/pds/e2e/firehose.e2e.ts b/packages/pds/e2e/firehose.e2e.ts index 8396ad11..27adf724 100644 --- a/packages/pds/e2e/firehose.e2e.ts +++ b/packages/pds/e2e/firehose.e2e.ts @@ -1,7 +1,14 @@ import { describe, it, expect, beforeAll } from "vitest"; import { AtpAgent } from "@atproto/api"; import WebSocket from "ws"; -import { createAgent, getPort, TEST_DID, TEST_HANDLE, TEST_PASSWORD, uniqueRkey } from "./helpers"; +import { + createAgent, + getPort, + TEST_DID, + TEST_HANDLE, + TEST_PASSWORD, + uniqueRkey, +} from "./helpers"; describe("Firehose (subscribeRepos)", () => { let agent: AtpAgent; diff --git a/packages/pds/e2e/helpers.ts b/packages/pds/e2e/helpers.ts index 523b94c6..c31b0aaf 100644 --- a/packages/pds/e2e/helpers.ts +++ b/packages/pds/e2e/helpers.ts @@ -1,7 +1,9 @@ import { AtpAgent } from "@atproto/api"; export function getPort(): number { - return ((globalThis as Record).__e2e_port__ as number) ?? 5173; + return ( + ((globalThis as Record).__e2e_port__ as number) ?? 5173 + ); } export function getBaseUrl(): string { diff --git a/packages/pds/e2e/session.e2e.ts b/packages/pds/e2e/session.e2e.ts index b7d26588..4048e035 100644 --- a/packages/pds/e2e/session.e2e.ts +++ b/packages/pds/e2e/session.e2e.ts @@ -1,10 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - createAgent, - TEST_DID, - TEST_HANDLE, - TEST_PASSWORD, -} from "./helpers"; +import { createAgent, TEST_DID, TEST_HANDLE, TEST_PASSWORD } from "./helpers"; describe("Session Authentication", () => { describe("createSession", () => { diff --git a/packages/pds/e2e/setup.ts b/packages/pds/e2e/setup.ts index 6f6a84c4..c1403003 100644 --- a/packages/pds/e2e/setup.ts +++ b/packages/pds/e2e/setup.ts @@ -71,9 +71,7 @@ function startViteServer(cwd: string): Promise { const timeout = setTimeout(() => { proc.kill(); reject( - new Error( - `Vite server startup timeout after 60s. Output:\n${output}`, - ), + new Error(`Vite server startup timeout after 60s. Output:\n${output}`), ); }, 60000); diff --git a/packages/pds/src/account-do.ts b/packages/pds/src/account-do.ts index 96a70668..735326f1 100644 --- a/packages/pds/src/account-do.ts +++ b/packages/pds/src/account-do.ts @@ -1091,7 +1091,10 @@ export class AccountDurableObject extends DurableObject { async rpcListMissingBlobs( limit: number = 500, cursor?: string, - ): Promise<{ blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }> { + ): Promise<{ + blobs: Array<{ cid: string; recordUri: string }>; + cursor?: string; + }> { const storage = await this.getStorage(); return storage.listMissingBlobs(limit, cursor); } diff --git a/packages/pds/src/cli/commands/activate.ts b/packages/pds/src/cli/commands/activate.ts index 8d2f3136..e9ea1f46 100644 --- a/packages/pds/src/cli/commands/activate.ts +++ b/packages/pds/src/cli/commands/activate.ts @@ -89,7 +89,7 @@ export const activateCommand = defineCommand({ // Show confirmation p.box( [ - pc.bold(`@${handle || "your-handle"}`), + pc.bold(`@${handle || "your-handle"}`), "", "This will enable writes and make your account live.", "Make sure you've:", diff --git a/packages/pds/src/cli/commands/deactivate.ts b/packages/pds/src/cli/commands/deactivate.ts index f3a8075b..b2d50e44 100644 --- a/packages/pds/src/cli/commands/deactivate.ts +++ b/packages/pds/src/cli/commands/deactivate.ts @@ -89,7 +89,11 @@ export const deactivateCommand = defineCommand({ // Show warning p.box( [ - pc.yellow(pc.bold(`⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`)), + pc.yellow( + pc.bold( + `⚠️ WARNING: This will disable writes for @${handle || "your-handle"}`, + ), + ), "", "Your account will:", " • Stop accepting new posts, follows, and other writes", diff --git a/packages/pds/src/cli/commands/init.ts b/packages/pds/src/cli/commands/init.ts index e3911d4c..219fa4ab 100644 --- a/packages/pds/src/cli/commands/init.ts +++ b/packages/pds/src/cli/commands/init.ts @@ -515,7 +515,8 @@ export const initCommand = defineCommand({ pc.cyan(" PDS hostname: ") + hostname, pc.cyan(" DID: ") + pc.dim(did), pc.cyan(" Handle: ") + pc.bold(handle), - pc.cyan(" Public signing key: ") + pc.dim(signingKeyPublic.slice(0, 20) + "..."), + pc.cyan(" Public signing key: ") + + pc.dim(signingKeyPublic.slice(0, 20) + "..."), "", isProduction ? pc.green("✓ Secrets deployed to Cloudflare ☁️") @@ -553,7 +554,9 @@ export const initCommand = defineCommand({ ? pc.bold("Deploy your worker and run the migration:") : pc.bold("Push secrets, deploy, and run the migration:"), "", - ...(deployedSecrets ? [] : [pc.cyan(" pnpm pds init --production"), ""]), + ...(deployedSecrets + ? [] + : [pc.cyan(" pnpm pds init --production"), ""]), pc.cyan(" wrangler deploy"), pc.cyan(" pnpm pds migrate"), "", diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index 06b242a1..940a52cd 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -221,7 +221,9 @@ export const migrateCommand = defineCommand({ return; } - spinner.start(`Fetching your account details from ${pc.cyan(sourceDomain)}...`); + spinner.start( + `Fetching your account details from ${pc.cyan(sourceDomain)}...`, + ); const sourceClient = new PDSClient(sourcePdsUrl); try { @@ -397,7 +399,9 @@ export const migrateCommand = defineCommand({ const preferences = await sourceClient.getPreferences(); if (preferences.length > 0) { await targetClient.putPreferences(preferences); - spinner.stop(`Migrated ${preferences.length} preference${preferences.length === 1 ? "" : "s"}`); + spinner.stop( + `Migrated ${preferences.length} preference${preferences.length === 1 ? "" : "s"}`, + ); } else { spinner.stop("No preferences to migrate"); } @@ -425,13 +429,15 @@ export const migrateCommand = defineCommand({ countCursor = page.cursor; } while (countCursor); - spinner.stop(`Found ${pc.yellow(formatNumber(totalBlobs))} images to transfer`); + spinner.stop( + `Found ${pc.yellow(formatNumber(totalBlobs))} images to transfer`, + ); // Use clack progress bar for transferring const progressBar = p.progress({ max: totalBlobs, - style: 'heavy', - size: 30 + style: "block", + size: 30, }); progressBar.start("Transferring images"); @@ -447,11 +453,17 @@ export const migrateCommand = defineCommand({ ); await targetClient.uploadBlob(bytes, mimeType); synced++; - progressBar.advance(1, `${pc.green(formatNumber(synced))}/${formatNumber(totalBlobs)} images transferred`); + progressBar.advance( + 1, + `${pc.green(formatNumber(synced))}/${formatNumber(totalBlobs)} images transferred`, + ); } catch (err) { synced++; failedBlobs.push(blob.cid); - progressBar.advance(1, `${synced}/${totalBlobs} images (${failedBlobs.length} failed)`); + progressBar.advance( + 1, + `${synced}/${totalBlobs} images (${failedBlobs.length} failed)`, + ); } } } while (cursor); diff --git a/packages/pds/src/cli/utils/cli-helpers.ts b/packages/pds/src/cli/utils/cli-helpers.ts index e64efcd9..0c06b2cf 100644 --- a/packages/pds/src/cli/utils/cli-helpers.ts +++ b/packages/pds/src/cli/utils/cli-helpers.ts @@ -5,7 +5,10 @@ /** * Get target PDS URL based on mode */ -export function getTargetUrl(isDev: boolean, pdsHostname: string | undefined): string { +export function getTargetUrl( + isDev: boolean, + pdsHostname: string | undefined, +): string { const LOCAL_PDS_URL = "http://localhost:5173"; if (isDev) { diff --git a/packages/pds/src/cli/utils/pds-client.ts b/packages/pds/src/cli/utils/pds-client.ts index 17458c10..de6d9c9f 100644 --- a/packages/pds/src/cli/utils/pds-client.ts +++ b/packages/pds/src/cli/utils/pds-client.ts @@ -199,7 +199,8 @@ export class PDSClient { } const bytes = new Uint8Array(await res.arrayBuffer()); - const mimeType = res.headers.get("content-type") ?? "application/octet-stream"; + const mimeType = + res.headers.get("content-type") ?? "application/octet-stream"; return { bytes, mimeType }; } @@ -262,11 +263,9 @@ export class PDSClient { * Export repository as CAR file */ async getRepo(did: string): Promise { - const { bytes } = await this.xrpcBytes( - "GET", - "com.atproto.sync.getRepo", - { params: { did } }, - ); + const { bytes } = await this.xrpcBytes("GET", "com.atproto.sync.getRepo", { + params: { did }, + }); return bytes; } @@ -417,7 +416,9 @@ export class PDSClient { */ async healthCheck(): Promise { try { - const res = await fetch(new URL("/xrpc/_health", this.baseUrl).toString()); + const res = await fetch( + new URL("/xrpc/_health", this.baseUrl).toString(), + ); return res.ok; } catch { return false; diff --git a/packages/pds/src/oauth.ts b/packages/pds/src/oauth.ts index 6cf6c2f3..46d4a048 100644 --- a/packages/pds/src/oauth.ts +++ b/packages/pds/src/oauth.ts @@ -208,7 +208,10 @@ export function createOAuthApp( } } catch { return c.json( - { error: "invalid_request", error_description: "Failed to parse request body" }, + { + error: "invalid_request", + error_description: "Failed to parse request body", + }, 400, ); } diff --git a/packages/pds/src/storage.ts b/packages/pds/src/storage.ts index 36df4f58..44c713e9 100644 --- a/packages/pds/src/storage.ts +++ b/packages/pds/src/storage.ts @@ -307,7 +307,7 @@ export class SqliteRepoStorage const rows = this.sql .exec("SELECT active FROM repo_state WHERE id = 1") .toArray(); - return rows.length > 0 ? ((rows[0]!.active as number) === 1) : true; + return rows.length > 0 ? (rows[0]!.active as number) === 1 : true; } /** diff --git a/packages/pds/test/migration.test.ts b/packages/pds/test/migration.test.ts index 31144a91..d7ca1621 100644 --- a/packages/pds/test/migration.test.ts +++ b/packages/pds/test/migration.test.ts @@ -524,23 +524,29 @@ describe("Account Migration", () => { it("activates a deactivated account", async () => { // First deactivate const deactivateResponse = await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.deactivateAccount`, { - method: "POST", - headers: { - Authorization: `Bearer ${env.AUTH_TOKEN}`, + new Request( + `http://pds.test/xrpc/com.atproto.server.deactivateAccount`, + { + method: "POST", + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, }, - }), + ), env, ); expect(deactivateResponse.ok).toBe(true); // Verify deactivated const statusResponse1 = await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.getAccountStatus`, { - headers: { - Authorization: `Bearer ${env.AUTH_TOKEN}`, + new Request( + `http://pds.test/xrpc/com.atproto.server.getAccountStatus`, + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, }, - }), + ), env, ); const status1 = (await statusResponse1.json()) as { active: boolean }; @@ -563,11 +569,14 @@ describe("Account Migration", () => { // Verify activated const statusResponse2 = await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.getAccountStatus`, { - headers: { - Authorization: `Bearer ${env.AUTH_TOKEN}`, + new Request( + `http://pds.test/xrpc/com.atproto.server.getAccountStatus`, + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, }, - }), + ), env, ); const status2 = (await statusResponse2.json()) as { active: boolean }; @@ -578,9 +587,12 @@ describe("Account Migration", () => { describe("com.atproto.server.deactivateAccount", () => { it("requires authentication", async () => { const response = await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.deactivateAccount`, { - method: "POST", - }), + new Request( + `http://pds.test/xrpc/com.atproto.server.deactivateAccount`, + { + method: "POST", + }, + ), env, ); @@ -613,12 +625,15 @@ describe("Account Migration", () => { // Deactivate const deactivateResponse = await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.deactivateAccount`, { - method: "POST", - headers: { - Authorization: `Bearer ${env.AUTH_TOKEN}`, + new Request( + `http://pds.test/xrpc/com.atproto.server.deactivateAccount`, + { + method: "POST", + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, }, - }), + ), env, ); @@ -628,11 +643,14 @@ describe("Account Migration", () => { // Verify deactivated const statusResponse = await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.getAccountStatus`, { - headers: { - Authorization: `Bearer ${env.AUTH_TOKEN}`, + new Request( + `http://pds.test/xrpc/com.atproto.server.getAccountStatus`, + { + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, }, - }), + ), env, ); const status = (await statusResponse.json()) as { active: boolean }; @@ -666,7 +684,6 @@ describe("Account Migration", () => { }); }); - describe("gg.mk.experimental.resetMigration", () => { it("requires authentication", async () => { const response = await worker.fetch( @@ -733,12 +750,15 @@ describe("Account Migration", () => { // Deactivate the account await worker.fetch( - new Request(`http://pds.test/xrpc/com.atproto.server.deactivateAccount`, { - method: "POST", - headers: { - Authorization: `Bearer ${env.AUTH_TOKEN}`, + new Request( + `http://pds.test/xrpc/com.atproto.server.deactivateAccount`, + { + method: "POST", + headers: { + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, }, - }), + ), env, ); diff --git a/packages/pds/test/oauth.test.ts b/packages/pds/test/oauth.test.ts index a6fd6d06..a24562a6 100644 --- a/packages/pds/test/oauth.test.ts +++ b/packages/pds/test/oauth.test.ts @@ -5,9 +5,7 @@ describe("OAuth 2.1 Endpoints", () => { describe("Server Metadata", () => { it("should return OAuth authorization server metadata", async () => { const response = await worker.fetch( - new Request( - "http://pds.test/.well-known/oauth-authorization-server", - ), + new Request("http://pds.test/.well-known/oauth-authorization-server"), env, ); expect(response.status).toBe(200); @@ -32,9 +30,7 @@ describe("OAuth 2.1 Endpoints", () => { it("should include PAR endpoint in metadata", async () => { const response = await worker.fetch( - new Request( - "http://pds.test/.well-known/oauth-authorization-server", - ), + new Request("http://pds.test/.well-known/oauth-authorization-server"), env, ); const metadata = await response.json(); @@ -45,9 +41,7 @@ describe("OAuth 2.1 Endpoints", () => { it("should return protected resource metadata", async () => { const response = await worker.fetch( - new Request( - "http://pds.test/.well-known/oauth-protected-resource", - ), + new Request("http://pds.test/.well-known/oauth-protected-resource"), env, ); expect(response.status).toBe(200); diff --git a/plans/complete/core-pds.md b/plans/complete/core-pds.md index 25456777..716ed5df 100644 --- a/plans/complete/core-pds.md +++ b/plans/complete/core-pds.md @@ -11,17 +11,20 @@ A single-user AT Protocol Personal Data Server (PDS) implemented on Cloudflare W ## Implemented Features ### Storage Layer (Phase 1) + - ✅ `SqliteRepoStorage` implementing `@atproto/repo` RepoStorage interface - ✅ SQLite schema for blocks, repo state, and firehose events - ✅ Atomic commit operations with transaction support ### Durable Object Architecture (Phase 2) + - ✅ `AccountDurableObject` with Repo integration - ✅ Lazy initialization with `blockConcurrencyWhile` - ✅ RPC-first architecture following DO best practices - ✅ Signing key import from environment with validation ### XRPC Endpoints (Phase 3) + - ✅ Sync endpoints: `getRepo`, `getRepoStatus`, `subscribeRepos` - ✅ Repository operations: `describeRepo`, `getRecord`, `listRecords`, `createRecord`, `deleteRecord`, `putRecord`, `applyWrites` - ✅ Server identity: `describeServer`, `resolveHandle` @@ -32,6 +35,7 @@ A single-user AT Protocol Personal Data Server (PDS) implemented on Cloudflare W - ✅ Preferences: `getPreferences`, `putPreferences` ### Firehose Implementation (Phase 4) + - ✅ WebSocket hibernation API handlers - ✅ DAG-CBOR frame encoding using `@atproto/lex-cbor` - ✅ Event broadcasting to connected clients @@ -40,17 +44,20 @@ A single-user AT Protocol Personal Data Server (PDS) implemented on Cloudflare W - ✅ SQLite `firehose_events` table with automatic pruning ### Blob Storage (Phase 5) + - ✅ R2 integration with `BlobStore` class - ✅ CID generation using `cidForRawBytes()` from `@atproto/lex-cbor` - ✅ 5MB upload limit enforcement - ✅ Direct R2 access in endpoints ### Identity & DID Documents (Phase 6) + - ✅ DID document served at `/.well-known/did.json` - ✅ Handle verification at `/.well-known/atproto-did` - ✅ Support for both did:web and did:plc identifiers ### Authentication (Phase 7) + - ✅ Bearer token middleware for write endpoints - ✅ Static token auth (AUTH_TOKEN) - ✅ JWT-based session authentication @@ -58,24 +65,28 @@ A single-user AT Protocol Personal Data Server (PDS) implemented on Cloudflare W - ✅ Access token + refresh token flow ### Session Authentication (Phase 8) + - ✅ JWT signing with HS256 (using jose library) - ✅ 60-minute access tokens, 90-day refresh tokens - ✅ Compatible with Bluesky app authentication - ✅ Password verification with bcryptjs ### Lexicon Validation (Phase 8) + - ✅ `RecordValidator` class using `@atproto/lexicon` - ✅ Optimistic validation strategy (fail-open) - ✅ Dynamic schema loading via Vite glob imports - ✅ Validation integrated into mutation endpoints ### Account Migration (Phase 9) + - ✅ CAR file import using `readCarWithRoot()` - ✅ Export/import workflow with validation - ✅ DID matching verification - ✅ Prevention of overwrites ### Protocol Helpers + - ✅ All operations use official @atproto utilities - ✅ TID generation via `TID.nextStr()` - ✅ AT-URI construction via `AtUri.make()` @@ -84,18 +95,21 @@ A single-user AT Protocol Personal Data Server (PDS) implemented on Cloudflare W - ✅ CAR export via `blocksToCarFile()` ### CLI Setup Wizard + - ✅ `pds init` - Interactive setup for production - ✅ `pds init --local` - Setup for local development - ✅ Secret management commands - ✅ Integration with wrangler config ### Testing + - ✅ 140+ tests covering all features - ✅ Vitest 4 with Cloudflare Workers pool - ✅ Durable Object testing support - ✅ Integration tests for federation ### DID Resolution & XRPC Proxy + - ✅ Full DID resolver for did:web and did:plc - ✅ DID caching with stale-while-revalidate - ✅ XRPC proxy with atproto-proxy header support @@ -127,24 +141,28 @@ A single-user AT Protocol Personal Data Server (PDS) implemented on Cloudflare W ## Configuration ### Environment Variables + - `PDS_HOSTNAME` - Public hostname of the PDS - `HANDLE` - Account handle - `DID` - Account DID - `SIGNING_KEY_PUBLIC` - Public key for DID document ### Secrets + - `SIGNING_KEY` - Private signing key (secp256k1) - `AUTH_TOKEN` - Bearer token for API access - `JWT_SECRET` - Secret for JWT signing - `PASSWORD_HASH` - bcrypt password hash ### Bindings + - `ACCOUNT` - DurableObjectNamespace - `BLOBS` - R2Bucket ## Dependencies All dependencies are Workers-compatible: + - `@atproto/repo` - Core MST and repository operations - `@atproto/crypto` - Cryptographic operations - `@atproto/syntax` - Protocol utilities diff --git a/plans/complete/oauth-provider.md b/plans/complete/oauth-provider.md index 1ef0cb29..1f792a73 100644 --- a/plans/complete/oauth-provider.md +++ b/plans/complete/oauth-provider.md @@ -14,6 +14,7 @@ OAuth 2.1 provider with AT Protocol extensions enabling "Login with Bluesky" eco A purpose-built OAuth 2.1 provider (not extending Cloudflare's OAuth provider) with: #### Core OAuth 2.1 + - **PKCE** (RFC 7636) - S256 challenge method only (per AT Protocol spec) - **DPoP** (RFC 9449) - Demonstrating Proof of Possession for token binding - **PAR** (RFC 9126) - Pushed Authorization Requests @@ -21,12 +22,14 @@ A purpose-built OAuth 2.1 provider (not extending Cloudflare's OAuth provider) w - Refresh token rotation #### AT Protocol Extensions + - **DID-based client discovery** - Resolves client metadata from `did:web` DIDs - **URL-based client IDs** - Also supports HTTPS URLs as client IDs - **Zod validation** - Uses `@atproto/oauth-types` for metadata validation - **atproto scope** - Single scope for AT Protocol access #### Security Features + - CSP headers on consent UI - DPoP key binding (prevents token theft) - Nonce replay prevention @@ -86,25 +89,25 @@ packages/pds/src/ ## OAuth Endpoints -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/.well-known/oauth-authorization-server` | GET | Server metadata discovery | -| `/oauth/authorize` | GET | Authorization endpoint (shows consent UI) | -| `/oauth/authorize` | POST | Consent form submission | -| `/oauth/token` | POST | Token endpoint (code exchange, refresh) | -| `/oauth/revoke` | POST | Token revocation | -| `/oauth/par` | POST | Pushed Authorization Request | +| Endpoint | Method | Description | +| ----------------------------------------- | ------ | ----------------------------------------- | +| `/.well-known/oauth-authorization-server` | GET | Server metadata discovery | +| `/oauth/authorize` | GET | Authorization endpoint (shows consent UI) | +| `/oauth/authorize` | POST | Consent form submission | +| `/oauth/token` | POST | Token endpoint (code exchange, refresh) | +| `/oauth/revoke` | POST | Token revocation | +| `/oauth/par` | POST | Pushed Authorization Request | ## Dependencies ```json { - "dependencies": { - "@atproto/crypto": "^0.4.5", - "@atproto/oauth-types": "^0.5.2", - "@atproto/syntax": "^0.4.2", - "jose": "^6.1.3" - } + "dependencies": { + "@atproto/crypto": "^0.4.5", + "@atproto/oauth-types": "^0.5.2", + "@atproto/syntax": "^0.4.2", + "jose": "^6.1.3" + } } ``` @@ -120,12 +123,12 @@ packages/pds/src/ import { ATProtoOAuthProvider } from "@ascorbic/atproto-oauth-provider"; const provider = new ATProtoOAuthProvider({ - issuer: "https://your-pds.com", - storage: yourOAuthStorage, - clientResolver: new ClientResolver({ storage: yourOAuthStorage }), - authenticateUser: async (username, password) => { - // Verify credentials, return DID or null - }, + issuer: "https://your-pds.com", + storage: yourOAuthStorage, + clientResolver: new ClientResolver({ storage: yourOAuthStorage }), + authenticateUser: async (username, password) => { + // Verify credentials, return DID or null + }, }); // Mount routes diff --git a/plans/todo/endpoint-implementation.md b/plans/todo/endpoint-implementation.md index b3618152..59b97062 100644 --- a/plans/todo/endpoint-implementation.md +++ b/plans/todo/endpoint-implementation.md @@ -7,11 +7,13 @@ This document tracks the implementation status of all AT Protocol XRPC endpoints ## Implementation Summary **Total Core PDS Endpoints: 70** + - ✅ **Implemented: 30** (43%) - ⚠️ **Partial/Stub: 3** (4%) - ❌ **Not Implemented: 37** (53%) **For Single-User PDS:** + - **Necessary endpoints implemented: 30/~32** (94%) - Most missing endpoints are multi-user, admin, or moderation features @@ -19,64 +21,65 @@ This document tracks the implementation status of all AT Protocol XRPC endpoints ### com.atproto.repo (10/11 - 91%) -| Endpoint | Status | Notes | -|----------|--------|-------| -| `applyWrites` | ✅ Complete | Batch operations, validates all records | -| `createRecord` | ✅ Complete | Validates against lexicon schemas | -| `deleteRecord` | ✅ Complete | Updates firehose | -| `describeRepo` | ✅ Complete | Returns collections and DID document | -| `getRecord` | ✅ Complete | With CID and value | -| `importRepo` | ✅ Complete | CAR file import with validation, blob tracking | -| `listMissingBlobs` | ✅ Complete | Lists blobs referenced but not imported | -| `listRecords` | ✅ Complete | Pagination, cursor, reverse | -| `putRecord` | ✅ Complete | Create or update with validation | -| `uploadBlob` | ✅ Complete | 5MB limit, R2 storage, tracks imports | +| Endpoint | Status | Notes | +| ------------------ | ----------- | ---------------------------------------------- | +| `applyWrites` | ✅ Complete | Batch operations, validates all records | +| `createRecord` | ✅ Complete | Validates against lexicon schemas | +| `deleteRecord` | ✅ Complete | Updates firehose | +| `describeRepo` | ✅ Complete | Returns collections and DID document | +| `getRecord` | ✅ Complete | With CID and value | +| `importRepo` | ✅ Complete | CAR file import with validation, blob tracking | +| `listMissingBlobs` | ✅ Complete | Lists blobs referenced but not imported | +| `listRecords` | ✅ Complete | Pagination, cursor, reverse | +| `putRecord` | ✅ Complete | Create or update with validation | +| `uploadBlob` | ✅ Complete | 5MB limit, R2 storage, tracks imports | ### com.atproto.sync (7/11 - 64%) -| Endpoint | Status | Notes | -|----------|--------|-------| -| `getBlob` | ✅ Complete | Direct R2 access | -| `getBlocks` | ✅ Complete | Returns CAR file with requested blocks | -| `getRepo` | ✅ Complete | CAR file export | -| `getRepoStatus` | ✅ Complete | Active status, rev, head | -| `listBlobs` | ✅ Complete | Paginated blob listing | -| `listRepos` | ✅ Complete | Returns single repo (single-user) | -| `subscribeRepos` | ✅ Complete | WebSocket firehose with CBOR frames | +| Endpoint | Status | Notes | +| ---------------- | ----------- | -------------------------------------- | +| `getBlob` | ✅ Complete | Direct R2 access | +| `getBlocks` | ✅ Complete | Returns CAR file with requested blocks | +| `getRepo` | ✅ Complete | CAR file export | +| `getRepoStatus` | ✅ Complete | Active status, rev, head | +| `listBlobs` | ✅ Complete | Paginated blob listing | +| `listRepos` | ✅ Complete | Returns single repo (single-user) | +| `subscribeRepos` | ✅ Complete | WebSocket firehose with CBOR frames | ### com.atproto.server (9/26 - 35%) -| Endpoint | Status | Notes | -|----------|--------|-------| -| `activateAccount` | ✅ Complete | Transition deactivated → active | -| `createSession` | ✅ Complete | JWT + static token auth | -| `deactivateAccount` | ✅ Complete | Transition active → deactivated | -| `deleteSession` | ✅ Complete | Stateless (client-side) | -| `describeServer` | ✅ Complete | Server capabilities | -| `getAccountStatus` | ✅ Complete | Returns activation state, repo metrics, blob counts | -| `getServiceAuth` | ✅ Complete | Service JWTs for AppView/external services | -| `getSession` | ✅ Complete | Current session info | -| `refreshSession` | ✅ Complete | Token refresh with validation | +| Endpoint | Status | Notes | +| ------------------- | ----------- | --------------------------------------------------- | +| `activateAccount` | ✅ Complete | Transition deactivated → active | +| `createSession` | ✅ Complete | JWT + static token auth | +| `deactivateAccount` | ✅ Complete | Transition active → deactivated | +| `deleteSession` | ✅ Complete | Stateless (client-side) | +| `describeServer` | ✅ Complete | Server capabilities | +| `getAccountStatus` | ✅ Complete | Returns activation state, repo metrics, blob counts | +| `getServiceAuth` | ✅ Complete | Service JWTs for AppView/external services | +| `getSession` | ✅ Complete | Current session info | +| `refreshSession` | ✅ Complete | Token refresh with validation | ### com.atproto.identity (1/6 - 17%) -| Endpoint | Status | Notes | -|----------|--------|-------| +| Endpoint | Status | Notes | +| --------------- | ---------- | ---------------------------------------------------- | | `resolveHandle` | ⚠️ Partial | Complete implementation (DNS + HTTPS for any handle) | -### app.bsky.* (3 endpoints) +### app.bsky.\* (3 endpoints) -| Endpoint | Status | Notes | -|----------|--------|-------| -| `actor.getPreferences` | ✅ Complete | Persists to SQLite | -| `actor.putPreferences` | ✅ Complete | Persists to SQLite | -| `ageassurance.getState` | ✅ Stub | Returns "assured" (self-hosted = pre-verified) | +| Endpoint | Status | Notes | +| ----------------------- | ----------- | ---------------------------------------------- | +| `actor.getPreferences` | ✅ Complete | Persists to SQLite | +| `actor.putPreferences` | ✅ Complete | Persists to SQLite | +| `ageassurance.getState` | ✅ Stub | Returns "assured" (self-hosted = pre-verified) | ## TODO Endpoints (Grouped by Priority) ### Migration Progress Tracking ✅ Complete All P1 migration endpoints have been implemented: + - ✅ `listMissingBlobs` - List blobs referenced but not uploaded - ✅ Enhanced `getAccountStatus` - Full migration metrics - ✅ `getBlocks` - Bulk block retrieval @@ -84,20 +87,20 @@ All P1 migration endpoints have been implemented: ### App Passwords (P2 - Important) -| Endpoint | Purpose | -|----------|---------| +| Endpoint | Purpose | +| ------------------- | --------------------------------------- | | `createAppPassword` | Create app-specific revocable passwords | -| `listAppPasswords` | List all app passwords | -| `revokeAppPassword` | Revoke specific app password | +| `listAppPasswords` | List all app passwords | +| `revokeAppPassword` | Revoke specific app password | **Total: 3 endpoints** ### Advanced Sync (P3 - Nice to Have) -| Endpoint | Purpose | -|----------|---------| -| `getLatestCommit` | Get latest commit without full repo | -| `getRecord` (sync) | Get record with merkle proof | +| Endpoint | Purpose | +| ------------------ | ----------------------------------- | +| `getLatestCommit` | Get latest commit without full repo | +| `getRecord` (sync) | Get record with merkle proof | **Total: 2 endpoints** @@ -114,12 +117,12 @@ May revisit if tools like Goat require it. ### PLC Operation Endpoints -| Endpoint | Reason | -|----------|--------| +| Endpoint | Reason | +| ------------------------------ | ------------------------------------- | | `getRecommendedDidCredentials` | Not needed - keys generated at deploy | -| `requestPlcOperationSignature` | Handled by old PDS during migration | -| `signPlcOperation` | Handled by old PDS during migration | -| `submitPlcOperation` | Handled by old PDS during migration | +| `requestPlcOperationSignature` | Handled by old PDS during migration | +| `signPlcOperation` | Handled by old PDS during migration | +| `submitPlcOperation` | Handled by old PDS during migration | PLC operations for migration are performed against the **old** PDS, not the new one. @@ -163,6 +166,7 @@ All `com.atproto.admin.*` endpoints ## Proxy Strategy All unimplemented `app.bsky.*` endpoints are proxied to `api.bsky.app` with service auth. This includes: + - Feeds (`app.bsky.feed.*`) - Graphs (`app.bsky.graph.*`) - Notifications (`app.bsky.notification.*`) @@ -176,6 +180,7 @@ This is intentional - the edge PDS focuses on repository operations and federate ### Phase 1: Account Lifecycle ✅ Complete Enable deactivated account pattern for migration: + - ✅ `activateAccount` - ✅ `deactivateAccount` - ✅ Deactivation guards on write operations @@ -184,6 +189,7 @@ Enable deactivated account pattern for migration: ### Phase 2: Migration Progress Tracking ✅ Complete Enable reliable migration with progress tracking: + - ✅ Add blob tracking infrastructure (`record_blob`, `imported_blobs` tables) - ✅ Enhance `getAccountStatus` with full metrics - ✅ Implement `listMissingBlobs` endpoint @@ -204,14 +210,14 @@ Efficient partial sync and merkle proofs. ## Endpoint Coverage by Namespace -| Namespace | Supported | Total | Coverage | -|-----------|-----------|-------|----------| -| `com.atproto.repo` | 10 | 11 | 91% | -| `com.atproto.sync` | 7 | 11 | 64% | -| `com.atproto.server` | 9 | 26 | 35% | -| `com.atproto.identity` | 1 | 6 | 17% | -| `com.atproto.admin` | 0 | 14 | 0% (intentional) | -| `app.bsky.*` | 3 | - | Proxy model | +| Namespace | Supported | Total | Coverage | +| ---------------------- | --------- | ----- | ---------------- | +| `com.atproto.repo` | 10 | 11 | 91% | +| `com.atproto.sync` | 7 | 11 | 64% | +| `com.atproto.server` | 9 | 26 | 35% | +| `com.atproto.identity` | 1 | 6 | 17% | +| `com.atproto.admin` | 0 | 14 | 0% (intentional) | +| `app.bsky.*` | 3 | - | Proxy model | ## References diff --git a/plans/todo/migration-wizard.md b/plans/todo/migration-wizard.md index 6ef797d5..9acf69d1 100644 --- a/plans/todo/migration-wizard.md +++ b/plans/todo/migration-wizard.md @@ -23,12 +23,14 @@ pnpm pds migrate --clean ``` ### What it does: + 1. 🔍 Auto-detects your current PDS from your DID document 2. 📦 Downloads your repository (posts, follows, likes) 3. 🖼️ Transfers all your images 4. 📊 Shows progress throughout ### Features: + - **Resumable** - Stop anytime, run again to continue - **Non-destructive** - Only works on deactivated accounts - **Progress tracking** - Shows exactly what's been transferred @@ -48,6 +50,7 @@ The "migration" is just: deploy deactivated → import data → activate. ### Required Secrets/Environment Variables **Infrastructure (always required):** + ``` PDS_HOSTNAME = "alice.example.com" JWT_SECRET = "" @@ -55,6 +58,7 @@ PASSWORD_HASH = "" ``` **Identity:** + ``` DID = "did:plc:xyz..." # For migration: the existing DID # For new account: generated by deploy script @@ -63,13 +67,13 @@ SIGNING_KEY = "" # Generated by deploy script ### How Configuration Works -| Value | Set By | Notes | -|-------|--------|-------| -| `PDS_HOSTNAME` | User | Their domain | -| `JWT_SECRET` | Deploy script | Generated | -| `PASSWORD_HASH` | User/script | From chosen password | -| `DID` | User (migration) or script (new) | Existing or generated | -| `SIGNING_KEY` | Deploy script | Always generated fresh | +| Value | Set By | Notes | +| --------------- | -------------------------------- | ---------------------- | +| `PDS_HOSTNAME` | User | Their domain | +| `JWT_SECRET` | Deploy script | Generated | +| `PASSWORD_HASH` | User/script | From chosen password | +| `DID` | User (migration) or script (new) | Existing or generated | +| `SIGNING_KEY` | Deploy script | Always generated fresh | ## Account States @@ -208,23 +212,23 @@ Account starts active (no deactivated state needed for new accounts). ### All Implemented -| Endpoint | Notes | -|----------|-------| -| `importRepo` | ✅ Import CAR file | -| `uploadBlob` | ✅ Upload individual blobs | -| `getAccountStatus` | ✅ Returns activation state + migration progress | -| `describeRepo` | ✅ Repository metadata | -| `getRepo` | ✅ Export as CAR | -| `activateAccount` | ✅ Enable writes | -| `deactivateAccount` | ✅ Disable writes | -| `listMissingBlobs` | ✅ Track blob migration progress | -| `gg.mk.experimental.resetMigration` | ✅ Reset to start fresh (deactivated only) | +| Endpoint | Notes | +| ----------------------------------- | ------------------------------------------------ | +| `importRepo` | ✅ Import CAR file | +| `uploadBlob` | ✅ Upload individual blobs | +| `getAccountStatus` | ✅ Returns activation state + migration progress | +| `describeRepo` | ✅ Repository metadata | +| `getRepo` | ✅ Export as CAR | +| `activateAccount` | ✅ Enable writes | +| `deactivateAccount` | ✅ Disable writes | +| `listMissingBlobs` | ✅ Track blob migration progress | +| `gg.mk.experimental.resetMigration` | ✅ Reset to start fresh (deactivated only) | ### Not Needed -| Endpoint | Reason | -|----------|--------| -| `createAccount` | Account created at deploy time | +| Endpoint | Reason | +| ----------------------- | ------------------------------ | +| `createAccount` | Account created at deploy time | | PLC operation endpoints | Handled externally via old PDS | ## Deactivation Guards @@ -240,12 +244,14 @@ When account is deactivated, these operations should fail with error: ``` **Blocked operations:** + - `createRecord` - `putRecord` - `deleteRecord` - `applyWrites` **Allowed operations:** + - All read operations - `importRepo` - `uploadBlob` @@ -255,14 +261,14 @@ When account is deactivated, these operations should fail with error: ### Implemented Commands -| Command | Description | -|---------|-------------| -| `pnpm pds init` | Interactive setup wizard (handles migration mode) | -| `pnpm pds migrate` | Transfer data from source PDS | -| `pnpm pds migrate --clean` | Reset migration and start fresh | -| `pnpm pds secret password` | Set account password | -| `pnpm pds secret jwt` | Generate JWT secret | -| `pnpm pds secret key` | Manage signing keys | +| Command | Description | +| -------------------------- | ------------------------------------------------- | +| `pnpm pds init` | Interactive setup wizard (handles migration mode) | +| `pnpm pds migrate` | Transfer data from source PDS | +| `pnpm pds migrate --clean` | Reset migration and start fresh | +| `pnpm pds secret password` | Set account password | +| `pnpm pds secret jwt` | Generate JWT secret | +| `pnpm pds secret key` | Manage signing keys | ### Setup Flow