diff --git a/tests/fixtures/offprint-standard-site-summer.json b/tests/fixtures/offprint-standard-site-summer.json new file mode 100644 index 0000000..af95c6a --- /dev/null +++ b/tests/fixtures/offprint-standard-site-summer.json @@ -0,0 +1,223 @@ +{ + "uri": "at://did:plc:pgjkomf37an4czloay5zeth6/site.standard.document/3moulnty5pq23", + "cid": "bafyreidheutshpypgkn6oqxo6qskzjgnrs7z63gdd6eora2t4fvj36xglm", + "value": { + "path": "/a/3moulnty5pq23-standardsite-summer-on-blueskys-tab", + "site": "at://did:plc:pgjkomf37an4czloay5zeth6/site.standard.publication/3mcqqd47cw22j", + "$type": "site.standard.document", + "title": "Standard.site Summer, on\u00a0Bluesky's tab", + "content": { + "$type": "app.offprint.content", + "items": [ + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 104, + "byteStart": 98 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#strikethrough" + } + ] + }, + { + "index": { + "byteEnd": 197, + "byteStart": 188 + }, + "features": [ + { + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", + "$type": "app.offprint.richtext.facet#mention", + "handle": "bsky.app" + } + ] + }, + { + "index": { + "byteEnd": 291, + "byteStart": 277 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#italic" + } + ] + } + ], + "plaintext": "For the rest of the summer, your first year of Offprint Pro is 25% off. That is $52/yr instead of $70/yr. Standard.site Summer deal is being ran in partnership with and funded by Bluesky (@bsky.app), they're paying for your summer of Pro. It applies automatically at checkout, no extra steps." + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "What Pro is? " + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Pro is the complete version of Offprint. You get a newsletter that reaches your own subscribers, a custom domain, more storage for your media, and the design tools to make your publication look like yours and not just a template." + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "Why Bluesky is funding it? " + }, + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 92, + "byteStart": 79 + }, + "features": [ + { + "did": "did:plc:pgjkomf37an4czloay5zeth6", + "$type": "app.offprint.richtext.facet#mention", + "handle": "offprint.app" + } + ] + }, + { + "index": { + "byteEnd": 116, + "byteStart": 106 + }, + "features": [ + { + "did": "did:plc:revjuqmkvrw6fnkxppqtszpv", + "$type": "app.offprint.richtext.facet#mention", + "handle": "pckt.blog" + } + ] + }, + { + "index": { + "byteEnd": 144, + "byteStart": 132 + }, + "features": [ + { + "did": "did:plc:btxrwcaeyodrap5mnjw2fvmz", + "$type": "app.offprint.richtext.facet#mention", + "handle": "leaflet.pub" + } + ] + }, + { + "index": { + "byteEnd": 206, + "byteStart": 193 + }, + "features": [ + { + "uri": "https://standard.site/?ref=offprint.app", + "$type": "app.offprint.richtext.facet#link" + } + ] + }, + { + "index": { + "byteEnd": 222, + "byteStart": 208 + }, + "features": [ + { + "did": "did:plc:re3ebnp5v7ffagz6rb6xfei4", + "$type": "app.offprint.richtext.facet#mention", + "handle": "standard.site" + } + ] + } + ], + "plaintext": "Bluesky is covering the first three months of every new annual plan. Offprint (@offprint.app), pckt.blog (@pckt.blog), and Leaflet (@leaflet.pub) are all built on the same open standard called Standard.site (@standard.site) and a healthier open social web means more writers owning their work instead of renting space on a closed platform. This is Bluesky investing in that." + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Feel free to read through their article here:" + }, + { + "href": "https://bsky.social/about/blog/06-22-2026-summer-of-standard-site", + "$type": "app.offprint.block.webBookmark", + "title": "Summer of Standard.site - Bluesky", + "preview": { + "ref": { + "$link": "bafkreiegxoimdzfga3wtwax3xzuuj5c4ush245idrtz4kmvhabmtwtjdeq" + }, + "size": 531012, + "$type": "blob", + "mimeType": "image/png" + }, + "siteName": "Bluesky", + "description": "We've partnered with three blogging platforms built on AT Protocol \u2014 Offprint, Leaflet, and pckt.blog \u2014 to give you 25% off any annual plan this summer." + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "How to claim it?" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Start an annual Pro plan and the discount is applied automatically at checkout. If you are already on a monthly plan, switch to yearly and you get the same first-year price. Monthly pricing is unchanged." + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "How long it lasts?" + }, + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 36, + "byteStart": 23 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#bold" + } + ] + }, + { + "index": { + "byteEnd": 104, + "byteStart": 94 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#italic" + } + ] + } + ], + "plaintext": "The offer runs through July 31, 2026. It is a limited run, so if Pro has been sitting in your maybe pile, this is the moment." + }, + { + "$type": "app.offprint.block.blockquote", + "content": [ + { + "$type": "app.offprint.block.text", + "plaintext": "Own your writing, own your audience, take both anywhere." + } + ] + }, + { + "href": "https://offprint.link/summer-pro", + "text": "See Pro Pricing", + "$type": "app.offprint.block.button", + "alignment": "left" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "" + } + ] + }, + "description": "25% off your first year of Pro, funded by Bluesky", + "publishedAt": "2026-06-22T13:45:00+00:00", + "textContent": "For the rest of the summer, your first year of Offprint Pro is 25% off. That is $52/yr instead of $70/yr. Standard.site Summer deal is being ran in partnership with and funded by Bluesky (@bsky.app), they're paying for your summer of Pro. It applies automatically at checkout, no extra steps.\nWhat Pro is? \nPro is the complete version of Offprint. You get a newsletter that reaches your own subscribers, a custom domain, more storage for your media, and the design tools to make your publication look like yours and not just a template.\nWhy Bluesky is funding it? \nBluesky is covering the first three months of every new annual plan. Offprint (@offprint.app), pckt.blog (@pckt.blog), and Leaflet (@leaflet.pub) are all built on the same open standard called Standard.site (@standard.site) and a healthier open social web means more writers owning their work instead of renting space on a closed platform. This is Bluesky investing in that.\nFeel free to read through their article here:\nHow to claim it?\nStart an annual Pro plan and the discount is applied automatically at checkout. If you are already on a monthly plan, switch to yearly and you get the same first-year price. Monthly pricing is unchanged.\nHow long it lasts?\nThe offer runs through July 31, 2026. It is a limited run, so if Pro has been sitting in your maybe pile, this is the moment.\n> Own your writing, own your audience, take both anywhere." + } +} diff --git a/tests/fixtures/offprint-welcome-ghostoff.json b/tests/fixtures/offprint-welcome-ghostoff.json new file mode 100644 index 0000000..87906b5 --- /dev/null +++ b/tests/fixtures/offprint-welcome-ghostoff.json @@ -0,0 +1,437 @@ +{ + "uri": "at://did:plc:bbj62ki3gmlsjfzsr3dxbkna/site.standard.document/3mpnksyarka23", + "cid": "bafyreiarnnsl74aabr2aw6ri6y2wipvg56rqkg32sb5ib33oq27ratadxi", + "value": { + "path": "/a/3mpnksyarka23-welcome-to-ghostoff", + "site": "at://did:plc:bbj62ki3gmlsjfzsr3dxbkna/site.standard.publication/3mpnksxsxsn2l", + "$type": "site.standard.document", + "title": "Welcome to GhostOff", + "content": { + "$type": "app.offprint.content", + "items": [ + { + "$type": "app.offprint.block.text", + "plaintext": "This is your space to publish. What you write here belongs to you and travels with your identity across the network." + }, + { + "$type": "app.offprint.block.heading", + "level": 2, + "plaintext": "Why We Built This" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Most publishing platforms treat your content as their asset. Your words live on their servers, governed by their rules, subject to their business decisions. If the platform changes direction or disappears, your archive goes with it." + }, + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 83, + "byteStart": 72 + }, + "features": [ + { + "uri": "https://atproto.com/", + "$type": "app.offprint.richtext.facet#link" + } + ] + } + ], + "plaintext": "Offprint works differently. Your content is stored on the decentralized AT Protocol network. Your readers can find you through your handle, and your work remains accessible regardless of what happens to any single service." + }, + { + "$type": "app.offprint.block.blockquote", + "content": [ + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 52, + "byteStart": 0 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#italic" + } + ] + } + ], + "plaintext": "\"The best way to predict the future is to build it.\"" + } + ] + }, + { + "$type": "app.offprint.block.text", + "plaintext": "We took that idea seriously. Rather than waiting for the open social web to mature, we are building the tools that help it get there." + }, + { + "$type": "app.offprint.block.callout", + "emoji": "\ud83d\udde3\ufe0f", + "facets": [ + { + "index": { + "byteEnd": 28, + "byteStart": 0 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#bold" + } + ] + } + ], + "plaintext": "Your content, your identity. Everything you publish through Offprint is tied to your AT Protocol identity. Switch clients or services whenever you want without losing your work or your audience." + }, + { + "$type": "app.offprint.block.heading", + "level": 2, + "plaintext": "What You Can Create" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Offprint supports long-form writing with the formatting options that matter." + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "Articles and Essays" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Write detailed pieces with structure. Use headings to organize sections, quotes to highlight key ideas, and images to support your narrative." + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "Technical Documentation" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "For developers and technical writers, code blocks render with syntax highlighting:" + }, + { + "code": "// Your code stays readable\nconst post = await agent.getPost({ uri });\nconsole.log(post.data);", + "$type": "app.offprint.block.codeBlock", + "language": "javascript" + }, + { + "$type": "app.offprint.block.heading", + "level": 3, + "plaintext": "Visual Stories" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Images can stand alone or sit together in a grid, useful for photo essays, design showcases, or any content where visuals carry the story." + }, + { + "alt": "A showcase of what Offprint can do", + "$type": "app.offprint.block.image", + "image": { + "ref": { + "$link": "bafkreibtmfw6ikcwknj7jir25tj445t7efkcrub7iiob7ywav5tu55sv7u" + }, + "size": 642684, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "alignment": "center", + "aspectRatio": { + "width": 1440, + "height": 925 + } + }, + { + "$type": "app.offprint.block.imageGrid", + "images": [ + { + "alt": "Feature one", + "image": { + "ref": { + "$link": "bafkreieo53qejzo7prd6i5pepoq74t6733t6swgdlehxxowe5hg6bfxvyq" + }, + "size": 437127, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "aspectRatio": { + "width": 960, + "height": 1440 + } + }, + { + "alt": "Feature two", + "image": { + "ref": { + "$link": "bafkreier2u6fufgbobfvvkrrv5rzu2sifdndkuxt3jr52og2nkmti4atje" + }, + "size": 451580, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "aspectRatio": { + "width": 1440, + "height": 960 + } + }, + { + "alt": "Feature three", + "image": { + "ref": { + "$link": "bafkreigond5g7vuojv64jna4vk2tf2edvw2mudwjuynnw56ikowgxehktq" + }, + "size": 202686, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "aspectRatio": { + "width": 1152, + "height": 1440 + } + }, + { + "alt": "Feature four", + "image": { + "ref": { + "$link": "bafkreihke3xh7i2lmhvfdfkf2yvhsvo3tkz7zycmf7vu5qorq73g3bjm2m" + }, + "size": 301784, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "aspectRatio": { + "width": 1440, + "height": 960 + } + }, + { + "alt": "Feature five", + "image": { + "ref": { + "$link": "bafkreibpohri42l7e7bh5lb327ie32yitsfqe2r4sonjm72wxcenebbsti" + }, + "size": 303074, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "aspectRatio": { + "width": 871, + "height": 1440 + } + }, + { + "alt": "Feature six", + "image": { + "ref": { + "$link": "bafkreibxyfhimjhqmhsyxbqi26nrvsqao4rzyk6cavcdf22svhzibv47im" + }, + "size": 347441, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "aspectRatio": { + "width": 1440, + "height": 1080 + } + } + ], + "gridRows": 2, + "aspectRatio": "mosaic" + }, + { + "$type": "app.offprint.block.horizontalRule" + }, + { + "$type": "app.offprint.block.heading", + "level": 2, + "plaintext": "Built on Open Standards" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "Offprint is part of a broader effort to make decentralized publishing practical and reliable. A few principles guide that work:" + }, + { + "$type": "app.offprint.block.bulletList", + "children": [ + { + "content": { + "$type": "app.offprint.block.text", + "plaintext": "Portability over lock-in" + } + }, + { + "content": { + "$type": "app.offprint.block.text", + "plaintext": "Longevity over trends" + } + }, + { + "content": { + "$type": "app.offprint.block.text", + "plaintext": "Clarity over cleverness" + } + } + ] + }, + { + "$type": "app.offprint.block.text", + "plaintext": "These are not just values we talk about. They shape the technical decisions behind this product." + }, + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 28, + "byteStart": 0 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#bold" + } + ] + } + ], + "plaintext": "What this means in practice:" + }, + { + "$type": "app.offprint.block.orderedList", + "children": [ + { + "content": { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 19, + "byteStart": 0 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#italic" + } + ] + } + ], + "plaintext": "Your published data is stored in open formats" + } + }, + { + "content": { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 34, + "byteStart": 17 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#italic" + } + ] + } + ], + "plaintext": "Your identity is controlled by you" + } + }, + { + "content": { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 62, + "byteStart": 24 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#italic" + } + ] + } + ], + "plaintext": "Your audience finds you through the protocol, not the platform" + } + } + ] + }, + { + "$type": "app.offprint.block.horizontalRule" + }, + { + "$type": "app.offprint.block.heading", + "level": 2, + "plaintext": "What Comes Next" + }, + { + "$type": "app.offprint.block.text", + "plaintext": "This is your publication. You decide what belongs here." + }, + { + "$type": "app.offprint.block.taskList", + "children": [ + { + "checked": true, + "content": { + "$type": "app.offprint.block.text", + "plaintext": "You have created your publication" + } + }, + { + "checked": true, + "content": { + "$type": "app.offprint.block.text", + "plaintext": "You are reading your first post" + } + }, + { + "checked": false, + "content": { + "$type": "app.offprint.block.text", + "plaintext": "Write something of your own" + } + }, + { + "checked": false, + "content": { + "$type": "app.offprint.block.text", + "plaintext": "Share it with the network" + } + } + ] + }, + { + "$type": "app.offprint.block.text", + "facets": [ + { + "index": { + "byteEnd": 33, + "byteStart": 0 + }, + "features": [ + { + "$type": "app.offprint.richtext.facet#highlight", + "color": "rgb(250 204 21 / 0.5)" + } + ] + } + ], + "plaintext": "The editor is ready when you are." + } + ] + }, + "coverImage": { + "ref": { + "$link": "bafkreibgjmvhvkg2lmytm3m3hgi5q2jkcvzsj6yltiyt35qymi4n5tmph4" + }, + "size": 27067, + "$type": "blob", + "mimeType": "image/jpeg" + }, + "description": "GhostOff is a publishing migration tool built for the open social web. It connects to the AT Protocol, giving your writing a home that is portable, permanent, and truly yours.", + "publishedAt": "2026-07-02T08:53:16+00:00", + "textContent": "This is your space to publish. What you write here belongs to you and travels with your identity across the network.\nWhy We Built This\nMost publishing platforms treat your content as their asset. Your words live on their servers, governed by their rules, subject to their business decisions. If the platform changes direction or disappears, your archive goes with it.\nOffprint works differently. Your content is stored on the decentralized AT Protocol network. Your readers can find you through your handle, and your work remains accessible regardless of what happens to any single service.\n> \"The best way to predict the future is to build it.\"\nWe took that idea seriously. Rather than waiting for the open social web to mature, we are building the tools that help it get there.\n\ud83d\udde3\ufe0f Your content, your identity. Everything you publish through Offprint is tied to your AT Protocol identity. Switch clients or services whenever you want without losing your work or your audience.\nWhat You Can Create\nOffprint supports long-form writing with the formatting options that matter.\nArticles and Essays\nWrite detailed pieces with structure. Use headings to organize sections, quotes to highlight key ideas, and images to support your narrative.\nTechnical Documentation\nFor developers and technical writers, code blocks render with syntax highlighting:\n// Your code stays readable\nconst post = await agent.getPost({ uri });\nconsole.log(post.data);\nVisual Stories\nImages can stand alone or sit together in a grid, useful for photo essays, design showcases, or any content where visuals carry the story.\nImages can stand alone with optional captions\n---\nBuilt on Open Standards\nOffprint is part of a broader effort to make decentralized publishing practical and reliable. A few principles guide that work:\n- Portability over lock-in\n- Longevity over trends\n- Clarity over cleverness\n\nThese are not just values we talk about. They shape the technical decisions behind this product.\nWhat this means in practice:\n- Your published data is stored in open formats\n- Your identity is controlled by you\n- Your audience finds you through the protocol, not the platform\n\n\n---\nWhat Comes Next\nThis is your publication. You decide what belongs here.\n[x] You have created your publication\n[x] You are reading your first post\n[ ] Write something of your own\n[ ] Share it with the network\n\nThe editor is ready when you are." + } +} -- 2.51.2 From cc618c9dade9a96e922d4299bebf6b06c037fc30 Mon Sep 17 00:00:00 2001 From: Boris Mann Date: Thu, 2 Jul 2026 12:37:26 -0700 Subject: [PATCH 2/6] Add OAuth manual testing guide --- docs/oauth_manual_testing.md | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/oauth_manual_testing.md diff --git a/docs/oauth_manual_testing.md b/docs/oauth_manual_testing.md new file mode 100644 index 0000000..a326f34 --- /dev/null +++ b/docs/oauth_manual_testing.md @@ -0,0 +1,99 @@ +# Manually testing the OAuth login flow + +This guide walks through a real browser-based OAuth login for GhostOff without disturbing your existing app-password session. + +## 1. Create a dedicated OAuth environment file + +Copy your existing `.env` and remove the app password: + +```bash +cp .env .env.oauth +``` + +Edit `.env.oauth` and **delete or comment out** `ATP_APP_PASSWORD`. Keep everything else, especially: + +```env +GHOST_URL=https://your-ghost-site.com +GHOST_API_KEY=your-ghost-content-api-key +ATP_IDENTIFIER=your-handle-or-did +ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... +``` + +If you omit `ATP_IDENTIFIER`, the CLI will prompt you for a handle at runtime. + +## 2. Run GhostOff with OAuth-only auth + +Use isolated session/state files so your default `ghostoff-oauth-session.json` is not affected: + +```bash +env $(cat .env.oauth | xargs) npm run dev -- \ + --oauth-session-file ghostoff-oauth-session-test.json \ + --oauth-state-file ghostoff-oauth-state-test.json +``` + +You can also add `--verbose` for more detail. + +## 3. Authenticate in the browser + +The CLI starts a temporary HTTP server on `127.0.0.1:` and prints an authorization URL: + +```text +Open this URL in your browser to authenticate GhostOff: +https://selfhosted.social/oauth/...?request_uri=...&client_id=http%3A%2F%2Flocalhost%2F%3F... +``` + +The CLI also tries to open the URL automatically. If it doesn't, copy and paste it into your browser. + +Log in to your PDS and approve GhostOff. Because this is a local-development OAuth client, the `client_id` defaults to `http://localhost/?redirect_uri=...` and the callback lands on `http://127.0.0.1:/callback`. + +## 4. Wait for the redirect + +After you approve, the PDS redirects to the local callback server. You should see: + +```text +Authentication successful. You can close this tab and return to the terminal. +``` + +In the terminal you should see something like: + +```text +authenticated as did:plc:... +session ready: your-handle.example.com (did:plc:...) +``` + +The migration then proceeds normally. + +## 5. Verify it uploaded + +Once the run finishes, check your Offprint publication to confirm the posts appeared. + +## If the PDS rejects the default `client_id` + +The atproto local-development exception expects the `client_id` origin to be `http://localhost` and the callback to be `127.0.0.1`. If your PDS insists on `http://127.0.0.1` as the `client_id` origin, override it: + +```bash +env $(cat .env.oauth | xargs) npm run dev -- \ + --oauth-client-id "http://127.0.0.1/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto%20transition%3Ageneric" \ + --oauth-session-file ghostoff-oauth-session-test.json \ + --oauth-state-file ghostoff-oauth-state-test.json +``` + +## Re-testing the full flow + +To force a fresh login, delete the test session file and run again: + +```bash +rm ghostoff-oauth-session-test.json +env $(cat .env.oauth | xargs) npm run dev -- \ + --oauth-session-file ghostoff-oauth-session-test.json \ + --oauth-state-file ghostoff-oauth-state-test.json +``` + +To test session restore instead, leave the session file in place and run the same command a second time. It should skip the browser step and use the cached OAuth session. + +## Troubleshooting + +- **Browser doesn't open:** copy the printed URL manually. +- **"OAuth state mismatch" or callback errors:** make sure you didn't start a second GhostOff process while the first is still waiting for the callback; the loopback server only handles one callback. +- **Callback never reaches the terminal:** ensure your browser is running on the same machine as the CLI, because the callback server binds to `127.0.0.1` only. +- **Migration prompts for a handle instead of using `ATP_IDENTIFIER`:** `ATP_IDENTIFIER` was empty; the prompt is the intended fallback. -- 2.51.2 From f0635d72f5dc88eeccadbb97b97ef3a643bdf2b3 Mon Sep 17 00:00:00 2001 From: Boris Mann Date: Thu, 2 Jul 2026 15:20:48 -0700 Subject: [PATCH 3/6] Note that ATP_SERVICE is optional when testing OAuth --- docs/oauth_manual_testing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/oauth_manual_testing.md b/docs/oauth_manual_testing.md index a326f34..2f2f170 100644 --- a/docs/oauth_manual_testing.md +++ b/docs/oauth_manual_testing.md @@ -19,6 +19,8 @@ ATP_IDENTIFIER=your-handle-or-did ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... ``` +You can leave `ATP_SERVICE` set or remove it; OAuth discovers the PDS from the handle, so the field is ignored. + If you omit `ATP_IDENTIFIER`, the CLI will prompt you for a handle at runtime. ## 2. Run GhostOff with OAuth-only auth -- 2.51.2 From 81f66a15de69114205e48149460142af60600508 Mon Sep 17 00:00:00 2001 From: Boris Mann Date: Thu, 2 Jul 2026 11:46:45 -0700 Subject: [PATCH 4/6] Add OAuth login flow with 127.0.0.1 loopback callback; keep app-password auth as fallback --- .env.example | 14 +- .gitignore | 2 + README.md | 27 +- package-lock.json | 623 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/assets.ts | 65 ++--- src/atproto.ts | 260 +++++++++++++++---- src/config.ts | 28 ++- src/index.ts | 48 ++-- src/oauth.ts | 109 ++++++++ 10 files changed, 1051 insertions(+), 128 deletions(-) create mode 100644 src/oauth.ts diff --git a/.env.example b/.env.example index 7c9dbd6..0cea246 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,16 @@ GHOST_URL=https://your-ghost-site.com GHOST_API_KEY=your-ghost-content-api-key +ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... + +# Auth: OAuth is the default. Set only if you want password auth. +# For OAuth, ATP_IDENTIFIER is optional on first run (you'll be prompted). +# For password auth, both ATP_IDENTIFIER and ATP_APP_PASSWORD are required. ATP_IDENTIFIER=your-handle-or-did -ATP_APP_PASSWORD=your-app-password +# ATP_APP_PASSWORD=your-app-password + +# ATP_SERVICE only matters for password auth. Leave as-is for OAuth. ATP_SERVICE=https://bsky.social -ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... + +# Optional OAuth configuration +# GHOSTOFF_OAUTH_CLIENT_ID=http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto%20transition%3Ageneric +# GHOSTOFF_OAUTH_SCOPE=atproto transition:generic diff --git a/.gitignore b/.gitignore index 1b40598..e6d8377 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ node_modules/ ghostoff-export/ ghostoff-session.json ghostoff-state.json +ghostoff-oauth-session.json +ghostoff-oauth-state.json # Build artifacts dist/ diff --git a/README.md b/README.md index 2dce570..77b549e 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ cp .env.example .env ```env GHOST_URL=https://your-ghost-site.com GHOST_API_KEY=your-ghost-content-api-key -ATP_IDENTIFIER=your-handle-or-did -ATP_APP_PASSWORD=your-app-password -ATP_SERVICE=https://selfhosted.social ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... + +# Auth: OAuth is the default. Set only if you want password auth. +ATP_IDENTIFIER=your-handle-or-did +# ATP_APP_PASSWORD=your-app-password ``` Run a dry first pass to inspect what would be uploaded: @@ -36,7 +37,15 @@ npm install npm run dev -- --dry-run ``` -When you're ready, run the real migration: +When you're ready, run the real migration with OAuth (default): + +```bash +npm run dev +``` + +If OAuth is not configured and you haven't set `ATP_IDENTIFIER`, the CLI will prompt for your handle and open a browser to `127.0.0.1` for authentication. + +To use an app password instead, set `ATP_APP_PASSWORD`: ```bash npm run dev @@ -52,11 +61,16 @@ Rerunning the same command will `putRecord` existing records instead of creating | `--ghost-api-key` | `GHOST_API_KEY` | Ghost Content API key | | `--atproto-identifier` | `ATP_IDENTIFIER` | atproto handle or DID | | `--atproto-app-password` | `ATP_APP_PASSWORD` | atproto app password | -| `--atproto-service` | `ATP_SERVICE` | PDS/service URL | +| `--atproto-service` | `ATP_SERVICE` | PDS/service URL (password auth only) | | `--publication-at-uri` | `ATPUBLICATION_AT_URI` | Existing `site.standard.publication` AT-URI | +| `--oauth-client-id` | `GHOSTOFF_OAUTH_CLIENT_ID` | OAuth client_id URL (dev default: `http://localhost`) | +| `--oauth-scope` | `GHOSTOFF_OAUTH_SCOPE` | OAuth scope (default: `atproto transition:generic`) | +| `--oauth-session-file` | — | OAuth session cache (default: `ghostoff-oauth-session.json`) | +| `--oauth-state-file` | — | OAuth transient state cache (default: `ghostoff-oauth-state.json`) | | `--dry-run` | — | Build records without uploading | | `--export-dir` | — | Local asset cache directory (default: `ghostoff-export`) | | `--state-file` | — | Idempotency state file (default: `ghostoff-state.json`) | +| `--session-file` | — | Password session cache (default: `ghostoff-session.json`) | | `--verbose` | — | Verbose logging | ## Scripts @@ -77,7 +91,8 @@ src/ ├── assets.ts # image download, cache, resize, blob upload ├── html-to-offprint.ts # Ghost HTML -> Offprint blocks ├── facets.ts # inline formatting -> richtext facets -├── atproto.ts # auth, record create/put, validation +├── atproto.ts # auth adapters (OAuth + password), record ops, validation +├── oauth.ts # loopback OAuth server and file-backed stores ├── state.ts # idempotency state file ├── rate-limit.ts # retry + rate-limit helpers └── types.ts # shared type definitions diff --git a/package-lock.json b/package-lock.json index 8d56cfc..80fe3dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,12 @@ "@atcute/lexicons": "^2.0.2", "@atcute/password-session": "^1.0.1", "@atcute/standard-site": "^2.0.2", + "@atproto/api": "^0.20.25", + "@atproto/oauth-client-node": "^0.4.5", "commander": "^12.1.0", "dotenv": "^16.4.7", "linkedom": "^0.18.9", + "open": "^11.0.0", "sharp": "^0.33.5" }, "devDependencies": { @@ -117,6 +120,338 @@ "unicode-segmenter": "^0.14.5" } }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.3.4.tgz", + "integrity": "sha512-nBECoVG59NfbYthayxfR0s1dLFVdN+RKMaL0p0uaNX/U1SAETksN6Yydmr4oWOKSkyyU3GO9XZeo1yzwHnRcZw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.3", + "@atproto-labs/pipe": "^0.2.3", + "@atproto-labs/simple-store": "^0.4.3", + "@atproto-labs/simple-store-memory": "^0.2.3", + "@atproto/did": "^0.5.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.3.tgz", + "integrity": "sha512-2gABLf0VEI86Sxo6YhGKQYK1tGhTZ4y+3TrCWputnupEZxuS/hw6+pFw9ceXZ3v9nnMNthzsAEcaAQ3V/tXsqg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "^0.2.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/fetch-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.3.4.tgz", + "integrity": "sha512-OTzgMG58RgZAnaX6OITh9qEW8kOeNvM8O5aJi9dHlK78AqW7OLhtJZnh1aIIkrJRhRYazNfegTVSdhlNn1TJ1A==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.3", + "@atproto-labs/pipe": "^0.2.3", + "ipaddr.js": "^2.1.0", + "undici_v6": "npm:undici@^6.x", + "undici_v7": "npm:undici@^7.x", + "undici_v8": "npm:undici@^8.x" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.4.4.tgz", + "integrity": "sha512-fQIcAQrsqmixI0Nt/3RRfBr/NLeK58lMFVCtlLoenjVOiVmI1xPBKvfktYTY3yvHcvMbo07r/RiE9ktG9nfCLQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.4.3", + "@atproto-labs/simple-store-memory": "^0.2.3", + "@atproto/did": "^0.5.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/handle-resolver-node": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.2.4.tgz", + "integrity": "sha512-2Vgu4ySIX5zEU6iDQtCdmap3byEogaAi4AmtH8qqU5WsFsjji1v7UbP4fYj4A1Gr5Htvza2YJ4vLNvg+DunRSA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch-node": "^0.3.4", + "@atproto-labs/handle-resolver": "^0.4.4", + "@atproto/did": "^0.5.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.4.3.tgz", + "integrity": "sha512-3VfQxHA+p/+FlvusCcYYJR6GFrA0qYWgNlwfCnCbW7VaEWl2Ztf3jAbaa5ZHYNu1WBJ0hMhuYC67zgEI+dSZfQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.4", + "@atproto-labs/handle-resolver": "^0.4.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.3.tgz", + "integrity": "sha512-hsjkaKGdhEGKhXuOOfIeYyZSQZfw007AXQJak/QTd9pLY1wHUJG85a9+u13xoMeav95gHkBRzswti7+kqsxgdw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.4.3.tgz", + "integrity": "sha512-ML1HAEtQIixkcU4FCGbZtAkoHTfmXDi63tNVuSSPG/cVUKyrUURg6Cr1bcfvbbkMTwRj9abEwa3JZR7UUYi4Ew==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.3.tgz", + "integrity": "sha512-RtL48op8Db/QFNbW+9Y+Os6DOMqysOkoG5QelTZ0qcZt/j0pUBY8v2zSr5/faVJ5Y30h1cONxMydV48tVWf8pg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.4.3", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/api": { + "version": "0.20.25", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.25.tgz", + "integrity": "sha512-PTwt6X0U45C9vikr8NRi0Qzcep9VqJet52HtfcxgG9R7ofRHxqLVPzfVuN/f2xOaYM6H5IG/Nk1E9kXZcI0mSQ==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.3", + "@atproto/lexicon": "^0.7.4", + "@atproto/syntax": "^0.6.4", + "@atproto/xrpc": "^0.8.3", + "await-lock": "^3.0.0", + "multiformats": "^13.0.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.3.tgz", + "integrity": "sha512-FkMhOcNv1y7r5984+zXB+uMN4zJ4QFLEbZrYyQo6bNCf/Kfav/pEHXXENlaZEOweq2NYXf+tr2giMssBuM9u5A==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.4", + "@atproto/lex-json": "^0.1.3", + "@atproto/syntax": "^0.6.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/did": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.3.tgz", + "integrity": "sha512-nKcdu5qB9iNJFaBeQOQUJ+6mA1npFcZ3DmD0xB+etkMRd/3UvOQql/CvcMZaYzl+eGoVs1JDdEsvoZz+idfhaw==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.7.3.tgz", + "integrity": "sha512-YK0rObYOZ4GphDvNtLyHnq6Z4sapSUESLKU6ty+uoAwK20NKBHep1rNfmiD0IurgZ2jnd3K9Rii49R51Qj4TEg==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.2.3.tgz", + "integrity": "sha512-y6V+G9qwsKwmMs7eIpSFJy62S/e6oEiGxnhysLPZ2S8m86/Ps/6vqEjTZqYttlc2NufnJnjzS7XQxY40Q0rVsg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.3", + "jose": "^5.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.3.3.tgz", + "integrity": "sha512-yOLro2nh8IFjLpN7VQP82NXLhlOcXrglU8mTJn1vHHjdv8M3ii8e9/ePAlR46cwBfCp0qc8kopcGp9wEjDcn9A==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.3", + "@atproto/jwk-jose": "^0.2.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.4.tgz", + "integrity": "sha512-f9U95sk0zUtxHktvK59peU+Shd0cIUYV68p//GS7sfdkAxUIeaspvX6CY+Quv9Oa4aozmsXI52SjaNn1wuU8RQ==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "tslib": "^2.8.1", + "uint8arrays": "^5.0.0", + "unicode-segmenter": "^0.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.3.tgz", + "integrity": "sha512-Ch2w9bCLFOwWINFFxpZo6DBW7+ZxkehciU3P8N94gSyENLe3/5WWXHdHvkiH7EXZrpI7fKY8nVWn4GIL0ttqxw==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.4.tgz", + "integrity": "sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.3", + "@atproto/syntax": "^0.6.4", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.7.7.tgz", + "integrity": "sha512-G9KEHtAaLhQsnIcriPULsEITC32tnjl5TnRYpMtxXj6A2rlMsgbfftC4pzG7g/v1X/Ap8VoSBZaNsSQ5PQ9xzQ==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.4", + "@atproto-labs/fetch": "^0.3.3", + "@atproto-labs/handle-resolver": "^0.4.4", + "@atproto-labs/identity-resolver": "^0.4.3", + "@atproto-labs/simple-store": "^0.4.3", + "@atproto-labs/simple-store-memory": "^0.2.3", + "@atproto/did": "^0.5.3", + "@atproto/jwk": "^0.7.3", + "@atproto/oauth-types": "^0.7.4", + "@atproto/xrpc": "^0.8.3", + "core-js": "^3", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-node": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.4.5.tgz", + "integrity": "sha512-AdhqrjbOmkTKVSI87Ult+50F8DIZYMOYieAiAjQE3z5u/wmL8MgFZ9b5VXzo0tDp9yteftjTm2tAQ+PPXf3xHg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.4", + "@atproto-labs/handle-resolver-node": "^0.2.4", + "@atproto-labs/simple-store": "^0.4.3", + "@atproto/did": "^0.5.3", + "@atproto/jwk": "^0.7.3", + "@atproto/jwk-jose": "^0.2.3", + "@atproto/jwk-webcrypto": "^0.3.3", + "@atproto/oauth-client": "^0.7.7", + "@atproto/oauth-types": "^0.7.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.7.4.tgz", + "integrity": "sha512-LI6fTaj+G3uPptKADJyiQl36qCFgpBUAzAzCCJUOgavdTq2p3ZY+lz/YjwZjyXcef8fEr8LPT9cMDbHhrvs0og==", + "license": "MIT", + "dependencies": { + "@atproto/did": "^0.5.3", + "@atproto/jwk": "^0.7.3", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.6.4.tgz", + "integrity": "sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/xrpc": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.3.tgz", + "integrity": "sha512-0gUGN71+bSqV3PI5U4cQ4fdnw4nI0eD6czHDQ6jB7hMYBwNcJpwAyfA9W+C1G1wayIvP0SIap/M0H/pSKDWFIQ==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.7.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", @@ -946,12 +1281,33 @@ "undici-types": "~6.21.0" } }, + "node_modules/await-lock": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-3.0.0.tgz", + "integrity": "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1002,6 +1358,17 @@ "node": ">=18" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1036,6 +1403,46 @@ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1224,12 +1631,96 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-arrayish": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/linkedom": { "version": "0.18.12", "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", @@ -1254,6 +1745,18 @@ } } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -1266,6 +1769,50 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1326,12 +1873,20 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.22.4", @@ -1372,6 +1927,45 @@ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", "license": "ISC" }, + "node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/undici_v6": { + "name": "undici", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici_v7": { + "name": "undici", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici_v8": { + "name": "undici", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.6.0.tgz", + "integrity": "sha512-l2FlC6I510GawyEd1qgcE/okihKrzy+BRTEBlu6T0fdbM9m5yxtIH5Oa3ysRsH0zC4EhmWUEaSDsy2QngBeRlw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1398,6 +1992,31 @@ "optional": true } } + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 61f64fa..977236a 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,12 @@ "@atcute/lexicons": "^2.0.2", "@atcute/password-session": "^1.0.1", "@atcute/standard-site": "^2.0.2", + "@atproto/api": "^0.20.25", + "@atproto/oauth-client-node": "^0.4.5", "commander": "^12.1.0", "dotenv": "^16.4.7", "linkedom": "^0.18.9", + "open": "^11.0.0", "sharp": "^0.33.5" }, "devDependencies": { diff --git a/src/assets.ts b/src/assets.ts index 62d70d7..ad82c80 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -5,7 +5,8 @@ import sharp from 'sharp'; import type { BlobRef } from './types.js'; import type { Logger } from './logger.js'; import { writeFile, readFile, stat } from 'node:fs/promises'; -import { respectRateLimit, shouldRetry, backoffDelay, parseRateLimitHeaders, sleep } from './rate-limit.js'; +import { sleep, backoffDelay } from './rate-limit.js'; +import type { AtprotoSession } from './atproto.js'; export interface AssetInfo { url: string; @@ -22,7 +23,7 @@ export interface AssetState { height?: number; } -const BLOB_SIZE_LIMIT = 900_000; // stay comfortably under the 1 MB atproto limit +const BLOB_SIZE_LIMIT = 900_000; export function resolveUrl(base: string, url: string): string { if (!url) return url; @@ -67,7 +68,7 @@ export async function downloadImage(url: string, localPath: string, log: Logger) log.debug(`using cached image: ${url}`); return readFile(localPath); } catch { - // not cached, continue to download + // not cached } const response = await fetch(url, { @@ -89,12 +90,10 @@ async function processImageBuffer(buffer: Buffer, mimeType: string, log: Logger) let width = metadata.width ?? 1; let height = metadata.height ?? 1; - // Resize very large images first if (width > 2048 || height > 2048) { image = image.resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true }); } - // Use JPEG for the blob unless the source is a PNG that is already small const targetFormat = mimeType === 'image/png' && buffer.length <= BLOB_SIZE_LIMIT ? 'png' : 'jpeg'; for (let attempt = 0; attempt < 10; attempt++) { @@ -116,7 +115,6 @@ async function processImageBuffer(buffer: Buffer, mimeType: string, log: Logger) }; } - // If still too large, shrink the dimensions image = image.resize({ width: Math.max(400, Math.round(width * 0.7)), height: Math.max(400, Math.round(height * 0.7)), fit: 'inside' }); const nextMeta = await image.metadata(); width = nextMeta.width ?? width; @@ -131,8 +129,7 @@ export async function getOrUploadImage( base: string, exportDir: string, stateAssets: Record, - accessJwt: string, - service: string, + session: AtprotoSession, log: Logger ): Promise { const resolved = resolveUrl(base, url); @@ -172,52 +169,30 @@ export async function getOrUploadImage( } log.debug(`uploading blob for ${resolved} (${processed.buffer.length} bytes)`); - const uploadUrl = new URL('/xrpc/com.atproto.repo.uploadBlob', service); - let response!: Response; + let blobRef: BlobRef | undefined; for (let attempt = 0; attempt < 3; attempt++) { - response = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessJwt}`, - 'Content-Type': processed.mimeType, - Accept: 'application/json', - }, - body: new Uint8Array(processed.buffer), - }); - - if (response.ok) break; - - const text = await response.text().catch(() => response.statusText); - log.warn(`blob upload attempt ${attempt + 1} failed (${resolved}): ${response.status} ${text}`); - - if (attempt < 2 && shouldRetry(response.status)) { - await respectRateLimit(response, log, 'uploadBlob'); - await sleep(backoffDelay(attempt)); - continue; + try { + blobRef = await session.uploadBlob(processed.buffer, processed.mimeType, log); + break; + } catch (err: any) { + log.warn(`blob upload attempt ${attempt + 1} failed (${resolved}): ${err?.message ?? err}`); + if (attempt < 2) { + await sleep(backoffDelay(attempt)); + continue; + } + log.error(`blob upload failed (${resolved}): ${err?.message ?? err}`); + return undefined; } - log.error(`blob upload failed (${resolved}): ${response.status} ${text}`); - return undefined; } - if (!response.ok) { + if (!blobRef) { return undefined; } - const json = (await response.json()) as { blob: BlobRef }; - if (!json.blob) { - log.error(`blob upload returned no blob ref (${resolved})`); - return undefined; - } - - const rateInfo = parseRateLimitHeaders(response.headers); - if (rateInfo?.remaining !== undefined) { - log.debug(`uploadBlob rate limit: ${rateInfo.remaining}/${rateInfo.limit} remaining`); - } - stateAssets[resolved] = { url: resolved, - blob: json.blob, + blob: blobRef, width: processed.width, height: processed.height, }; @@ -225,7 +200,7 @@ export async function getOrUploadImage( return { url: resolved, localPath, - blob: json.blob, + blob: blobRef, width: processed.width, height: processed.height, }; diff --git a/src/atproto.ts b/src/atproto.ts index ae7fd0b..51f8a13 100644 --- a/src/atproto.ts +++ b/src/atproto.ts @@ -1,23 +1,41 @@ +import { randomBytes } from 'node:crypto'; +import { readFile, writeFile, chmod } from 'node:fs/promises'; import { Client, retryFetchHandler } from '@atcute/client'; import { PasswordSession } from '@atcute/password-session'; import type { PasswordSessionData } from '@atcute/password-session'; import { parseResourceUri } from '@atcute/lexicons/syntax'; import { safeParse } from '@atcute/lexicons'; import { SiteStandardDocument } from '@atcute/standard-site'; -import { readFile, writeFile } from 'node:fs/promises'; +import { NodeOAuthClient, OAuthSession } from '@atproto/oauth-client-node'; import type { Config } from './config.js'; import type { BlobRef } from './types.js'; import type { Logger } from './logger.js'; +import { + createFileSessionStore, + createFileStateStore, + promptForHandle, + startLoopbackCallbackServer, +} from './oauth.js'; export type { BlobRef }; export interface AtprotoSession { - client: Client; did: string; handle: string; - accessJwt: string; service: string; - passwordSession: PasswordSession; + client: Client; + uploadBlob(buffer: Buffer, mimeType: string, log: Logger): Promise; +} + +const LOCAL_REDIRECT_PATH = '/callback'; +const LOCAL_REDIRECT_URI = 'http://127.0.0.1/callback'; + +function localRedirectUri(port: number): string { + return `http://127.0.0.1:${port}${LOCAL_REDIRECT_PATH}`; +} + +function buildLocalClientId(scope: string): string { + return `http://localhost/?redirect_uri=${encodeURIComponent(LOCAL_REDIRECT_URI)}&scope=${encodeURIComponent(scope)}`; } async function readSessionFile(path: string): Promise { @@ -30,15 +48,104 @@ async function readSessionFile(path: string): Promise { - await writeFile(path, JSON.stringify(data, null, 2), 'utf-8'); + await writeFile(path, JSON.stringify(data, null, 2), { mode: 0o600 }); } -export async function getSession(config: Config, log: Logger): Promise { +class PasswordSessionAdapter implements AtprotoSession { + private passwordSession: PasswordSession; + client: Client; + did: string; + handle: string; + service: string; + + constructor(passwordSession: PasswordSession) { + this.passwordSession = passwordSession; + this.client = new Client({ + handler: retryFetchHandler({ handler: passwordSession, maxRetries: 3 }), + }); + this.did = passwordSession.did; + this.handle = passwordSession.session.handle; + this.service = passwordSession.session.service; + } + + async uploadBlob(buffer: Buffer, mimeType: string, log: Logger): Promise { + const url = new URL('/xrpc/com.atproto.repo.uploadBlob', this.passwordSession.dispatchUrl); + let response!: Response; + for (let attempt = 0; attempt < 3; attempt++) { + response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.passwordSession.session.accessJwt}`, + 'Content-Type': mimeType, + Accept: 'application/json', + }, + body: new Uint8Array(buffer), + }); + if (response.ok) break; + const text = await response.text().catch(() => response.statusText); + if (attempt < 2) { + log.warn(`password uploadBlob attempt ${attempt + 1} failed: ${response.status} ${text}`); + await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); + continue; + } + throw new Error(`uploadBlob failed ${response.status}: ${text}`); + } + const json = (await response.json()) as { blob: BlobRef }; + if (!json.blob) { + throw new Error('uploadBlob response missing blob'); + } + return json.blob; + } +} + +class OAuthSessionAdapter implements AtprotoSession { + oauthSession: OAuthSession; + client: Client; + did: string; + handle: string; + service: string; + + constructor(oauthSession: OAuthSession) { + this.oauthSession = oauthSession; + this.client = new Client({ + handler: retryFetchHandler({ handler: oauthSession.fetchHandler.bind(oauthSession), maxRetries: 3 }), + }); + this.did = oauthSession.did; + this.handle = ''; + this.service = oauthSession.serverMetadata.issuer ?? ''; + } + + async uploadBlob(buffer: Buffer, mimeType: string): Promise { + const response = await this.oauthSession.fetchHandler('/xrpc/com.atproto.repo.uploadBlob', { + method: 'POST', + headers: { + 'Content-Type': mimeType, + Accept: 'application/json', + }, + body: new Uint8Array(buffer), + }); + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(`uploadBlob failed ${response.status}: ${text}`); + } + const json = (await response.json()) as { blob: BlobRef }; + if (!json.blob) { + throw new Error('uploadBlob response missing blob'); + } + return json.blob; + } +} + +async function getPasswordSession(config: Config, log: Logger): Promise { + if (!config.atpIdentifier || !config.atpAppPassword) { + throw new Error('Missing ATP_IDENTIFIER and/or ATP_APP_PASSWORD for password auth'); + } + const cached = await readSessionFile(config.sessionFile); let session: PasswordSession; if (cached) { - log.debug('resuming cached atproto session'); + log.debug('resuming cached password session'); session = await PasswordSession.resume(cached, { onUpdate(data) { writeSessionFile(config.sessionFile, data).catch(() => {}); @@ -66,53 +173,120 @@ export async function getSession(config: Config, log: Logger): Promise { + let savedSessions: Record; + try { + const raw = await readFile(sessionStorePath, 'utf-8'); + savedSessions = JSON.parse(raw) as Record; + } catch { + return undefined; } - return { - client, - did: sessionInfo.data.did, - handle: sessionInfo.data.handle, - accessJwt: data.accessJwt, - service: config.atpService, - passwordSession: session, - }; + const subs = Object.keys(savedSessions); + if (subs.length === 0) return undefined; + + for (const sub of subs) { + try { + const session = await client.restore(sub); + log.debug(`restored OAuth session for ${sub}`); + return session; + } catch (err: any) { + log.debug(`failed to restore OAuth session for ${sub}: ${err?.message ?? err}`); + } + } + return undefined; } -export async function uploadBlob( - session: AtprotoSession, - buffer: Buffer, - mimeType: string, - log: Logger -): Promise { - const url = new URL('/xrpc/com.atproto.repo.uploadBlob', session.service); - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${session.accessJwt}`, - 'Content-Type': mimeType, - Accept: 'application/json', +async function startOAuthFlow(config: Config, log: Logger): Promise { + const scope = config.oauthScope; + const clientId = config.oauthClientId || buildLocalClientId(scope); + + const client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: 'GhostOff', + application_type: 'native', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [LOCAL_REDIRECT_URI], + scope, + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, }, - body: new Uint8Array(buffer), + stateStore: createFileStateStore(config.oauthStateFile), + sessionStore: createFileSessionStore(config.oauthSessionFile), }); - if (!response.ok) { - const text = await response.text().catch(() => response.statusText); - throw new Error(`uploadBlob failed ${response.status}: ${text}`); + let restored: OAuthSession | undefined; + try { + restored = await restoreOAuthSession(client, config.oauthSessionFile, log); + } catch { + // ignore + } + if (restored) return restored; + + const handle = config.atpIdentifier || (await promptForHandle()); + + const { port, getParams } = await startLoopbackCallbackServer(0); + const redirectUri = localRedirectUri(port); + const state = randomBytes(16).toString('base64url'); + + const authorizeUrl = await client.authorize(handle, { + redirect_uri: redirectUri as any, + state, + }); + + log.info('Open this URL in your browser to authenticate GhostOff:'); + log.info(authorizeUrl.toString()); + + try { + const { default: open } = await import('open'); + await open(authorizeUrl.toString()); + } catch { + // fallback to manual URL + } + + const params = await getParams(); + const error = params.get('error'); + if (error) { + throw new Error(`OAuth authorization error: ${error} - ${params.get('error_description') ?? ''}`); } - const json = (await response.json()) as { blob: BlobRef }; - if (!json.blob) { - throw new Error('uploadBlob response missing blob'); + const { session, state: returnedState } = await client.callback(params, { redirect_uri: redirectUri as any }); + if (returnedState !== state) { + throw new Error('OAuth state mismatch'); } - return json.blob; + + log.info(`authenticated as ${session.did}`); + await chmod(config.oauthSessionFile, 0o600).catch(() => {}); + await chmod(config.oauthStateFile, 0o600).catch(() => {}); + return session; +} + +export async function getSession(config: Config, log: Logger): Promise { + let adapter: AtprotoSession; + + if (config.atpAppPassword) { + const passwordSession = await getPasswordSession(config, log); + adapter = new PasswordSessionAdapter(passwordSession); + } else { + const oauthSession = await startOAuthFlow(config, log); + adapter = new OAuthSessionAdapter(oauthSession); + } + + if (!adapter.handle) { + const info = await adapter.client.get('com.atproto.server.getSession'); + if (!info.ok) { + throw new Error(`failed to get session info: ${(info.data as any).message ?? info.status}`); + } + adapter.handle = info.data.handle; + } + + log.info(`session ready: ${adapter.handle} (${adapter.did})`); + return adapter; } export interface StandardDocumentRecord { diff --git a/src/config.ts b/src/config.ts index 01238a3..aba56bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,13 +4,17 @@ import { program } from 'commander'; export interface Config { ghostUrl: string; ghostApiKey: string; - atpIdentifier: string; - atpAppPassword: string; + atpIdentifier?: string; + atpAppPassword?: string; atpService: string; publicationAtUri: string; exportDir: string; stateFile: string; sessionFile: string; + oauthClientId?: string; + oauthScope: string; + oauthSessionFile: string; + oauthStateFile: string; dryRun: boolean; verbose: boolean; } @@ -32,9 +36,13 @@ export function loadConfig(argv?: string[]): Config { .option('--atproto-app-password ', 'atproto app password') .option('--atproto-service ', 'atproto PDS/service URL') .option('--publication-at-uri ', 'existing site.standard.publication AT-URI') + .option('--oauth-client-id ', 'OAuth client_id URL (default: http://localhost dev mode)') + .option('--oauth-scope ', 'OAuth requested scope', 'atproto transition:generic') + .option('--oauth-session-file ', 'OAuth session store file', 'ghostoff-oauth-session.json') + .option('--oauth-state-file ', 'OAuth transient state store file', 'ghostoff-oauth-state.json') .option('--export-dir ', 'local asset export/cache directory', 'ghostoff-export') .option('--state-file ', 'idempotency state file', 'ghostoff-state.json') - .option('--session-file ', 'persisted session file', 'ghostoff-session.json') + .option('--session-file ', 'persisted password session file', 'ghostoff-session.json') .option('--dry-run', 'build records without uploading', false) .option('--verbose', 'verbose logging', false); @@ -43,14 +51,20 @@ export function loadConfig(argv?: string[]): Config { const ghostUrl = requireEnv('GHOST_URL or --ghost-url', opts.ghostUrl ?? process.env.GHOST_URL); const ghostApiKey = requireEnv('GHOST_API_KEY or --ghost-api-key', opts.ghostApiKey ?? process.env.GHOST_API_KEY); - const atpIdentifier = requireEnv('ATP_IDENTIFIER or --atproto-identifier', opts.atprotoIdentifier ?? process.env.ATP_IDENTIFIER); - const atpAppPassword = requireEnv('ATP_APP_PASSWORD or --atproto-app-password', opts.atpAppPassword ?? process.env.ATP_APP_PASSWORD); + const atpIdentifier = opts.atprotoIdentifier ?? process.env.ATP_IDENTIFIER ?? undefined; + const atpAppPassword = opts.atprotoAppPassword ?? process.env.ATP_APP_PASSWORD ?? undefined; const atpService = opts.atprotoService ?? process.env.ATP_SERVICE ?? 'https://bsky.social'; const publicationAtUri = requireEnv( 'ATPUBLICATION_AT_URI or --publication-at-uri', opts.publicationAtUri ?? process.env.ATPUBLICATION_AT_URI ); + if (!atpAppPassword && !atpIdentifier) { + throw new Error( + 'Missing configuration: provide ATP_IDENTIFIER/--atproto-identifier (for OAuth) or ATP_APP_PASSWORD/--atproto-app-password (for password auth).' + ); + } + return { ghostUrl: ghostUrl.replace(/\/$/, ''), ghostApiKey, @@ -61,6 +75,10 @@ export function loadConfig(argv?: string[]): Config { exportDir: opts.exportDir, stateFile: opts.stateFile, sessionFile: opts.sessionFile, + oauthClientId: opts.oauthClientId ?? process.env.GHOSTOFF_OAUTH_CLIENT_ID ?? undefined, + oauthScope: opts.oauthScope ?? process.env.GHOSTOFF_OAUTH_SCOPE ?? 'atproto transition:generic', + oauthSessionFile: opts.oauthSessionFile ?? 'ghostoff-oauth-session.json', + oauthStateFile: opts.oauthStateFile ?? 'ghostoff-oauth-state.json', dryRun: !!opts.dryRun, verbose: !!opts.verbose, }; diff --git a/src/index.ts b/src/index.ts index 07a9c43..d0658fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,6 @@ async function main() { log.info(`processing: ${post.title}`); const assetMap = new Map(); - // Collect and upload inline images const inlineUrls = collectImageUrls(post.html, config.ghostUrl); if (post.feature_image) { inlineUrls.push(post.feature_image); @@ -59,8 +58,7 @@ async function main() { config.ghostUrl, config.exportDir, state.assets as Record, - session.accessJwt, - config.atpService, + session, log ); if (info) { @@ -106,18 +104,18 @@ async function main() { if (config.dryRun || !session) { log.info(`[dry-run] would create/update site.standard.document for "${post.title}"`); documentRef = { uri: 'at://dry-run/site.standard.document/dry', cid: 'dry-run' }; - } else if (existing?.documentUri?.startsWith('at://did:') && existing.documentCid && existing.documentCid !== 'dry-run') { - documentRef = await putRecord( - session, - 'site.standard.document', - rkeyFromUri(existing.documentUri), - documentRecord, - existing.documentCid, - log - ); - } else { - documentRef = await createRecord(session, 'site.standard.document', documentRecord, log); - } + } else if (existing?.documentUri?.startsWith('at://did:') && existing.documentCid && existing.documentCid !== 'dry-run') { + documentRef = await putRecord( + session, + 'site.standard.document', + rkeyFromUri(existing.documentUri), + documentRecord, + existing.documentCid, + log + ); + } else { + documentRef = await createRecord(session, 'site.standard.document', documentRecord, log); + } const articleRecord = { $type: 'app.offprint.document.article', @@ -132,16 +130,16 @@ async function main() { if (config.dryRun || !session) { log.info(`[dry-run] would create/update app.offprint.document.article for "${post.title}"`); articleRef = { uri: 'at://dry-run/app.offprint.document.article/dry', cid: 'dry-run' }; - } else if (existing?.articleUri?.startsWith('at://did:') && existing.articleCid && existing.articleCid !== 'dry-run') { - articleRef = await putRecord( - session, - 'app.offprint.document.article', - rkeyFromUri(existing.articleUri), - articleRecord, - existing.articleCid, - log - ); - } else { + } else if (existing?.articleUri?.startsWith('at://did:') && existing.articleCid && existing.articleCid !== 'dry-run') { + articleRef = await putRecord( + session, + 'app.offprint.document.article', + rkeyFromUri(existing.articleUri), + articleRecord, + existing.articleCid, + log + ); + } else { articleRef = await createRecord(session, 'app.offprint.document.article', articleRecord, log); } diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..5e37fda --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,109 @@ +import { createServer } from 'node:http'; +import { readFile, writeFile } from 'node:fs/promises'; +import type { AddressInfo } from 'node:net'; +import { createInterface } from 'node:readline/promises'; +import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from '@atproto/oauth-client-node'; + +async function readJsonFile(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return {} as T; + } +} + +async function writeJsonFile(path: string, data: unknown, mode?: number): Promise { + await writeFile(path, JSON.stringify(data, null, 2), { mode }); +} + +export function createFileStateStore(filePath: string): NodeSavedStateStore { + return { + async set(key: string, value: NodeSavedState) { + const data = await readJsonFile>(filePath); + data[key] = value; + await writeJsonFile(filePath, data, 0o600); + }, + async get(key: string): Promise { + const data = await readJsonFile>(filePath); + return data[key]; + }, + async del(key: string) { + const data = await readJsonFile>(filePath); + delete data[key]; + await writeJsonFile(filePath, data, 0o600); + }, + }; +} + +export function createFileSessionStore(filePath: string): NodeSavedSessionStore { + return { + async set(sub: string, value: NodeSavedSession) { + const data = await readJsonFile>(filePath); + data[sub] = value; + await writeJsonFile(filePath, data, 0o600); + }, + async get(sub: string): Promise { + const data = await readJsonFile>(filePath); + return data[sub]; + }, + async del(sub: string) { + const data = await readJsonFile>(filePath); + delete data[sub]; + await writeJsonFile(filePath, data, 0o600); + }, + }; +} + +export function startLoopbackCallbackServer(port = 0): Promise<{ port: number; getParams: () => Promise }> { + return new Promise((resolve, reject) => { + let gotParams: ((params: URLSearchParams) => void) | undefined; + let rejectParams: ((err: Error) => void) | undefined; + + const paramsPromise = new Promise((res, rej) => { + gotParams = res; + rejectParams = rej; + }); + + const server = createServer((req, res) => { + const reqUrl = new URL(req.url || '/', 'http://127.0.0.1'); + if (reqUrl.pathname !== '/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); + return; + } + + const params = reqUrl.searchParams; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authentication successful. You can close this tab and return to the terminal.

'); + + server.close((closeErr) => { + if (closeErr) { + rejectParams?.(closeErr); + } else { + gotParams?.(params); + } + }); + }); + + server.on('error', reject); + + server.listen({ host: '127.0.0.1', port }, () => { + const address = server.address() as AddressInfo; + resolve({ + port: address.port, + getParams: () => paramsPromise, + }); + }); + }); +} + +export async function promptForHandle(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question('Enter your atproto handle (e.g. handle.example.com): '); + return answer.trim(); + } finally { + rl.close(); + } +} -- 2.51.2 From 63b5095d261f5f0d68572cd29adcab4eb2e639be Mon Sep 17 00:00:00 2001 From: Boris Mann Date: Thu, 2 Jul 2026 16:18:55 -0700 Subject: [PATCH 5/6] Prompt for OAuth when app password is missing; make ATP_IDENTIFIER optional for both auth methods --- README.md | 12 ++++----- docs/oauth_manual_testing.md | 47 +++++++++++++++++++----------------- src/atproto.ts | 13 +++++++++- src/config.ts | 6 ----- src/oauth.ts | 11 +++++++++ 5 files changed, 54 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 77b549e..11ec01c 100644 --- a/README.md +++ b/README.md @@ -37,20 +37,20 @@ npm install npm run dev -- --dry-run ``` -When you're ready, run the real migration with OAuth (default): +When you're ready, run the real migration: ```bash npm run dev ``` -If OAuth is not configured and you haven't set `ATP_IDENTIFIER`, the CLI will prompt for your handle and open a browser to `127.0.0.1` for authentication. +If you haven't set `ATP_APP_PASSWORD`, the CLI will ask: -To use an app password instead, set `ATP_APP_PASSWORD`: - -```bash -npm run dev +```text +No app password found in .env or --atproto-app-password. Would you like to authenticate via OAuth [Y/n]? ``` +Answer `Y` and it will open a browser so you can authenticate with your PDS. If you set `ATP_APP_PASSWORD`, it uses password auth instead. + Rerunning the same command will `putRecord` existing records instead of creating duplicates. ## CLI options diff --git a/docs/oauth_manual_testing.md b/docs/oauth_manual_testing.md index 2f2f170..d3b1099 100644 --- a/docs/oauth_manual_testing.md +++ b/docs/oauth_manual_testing.md @@ -1,39 +1,42 @@ # Manually testing the OAuth login flow -This guide walks through a real browser-based OAuth login for GhostOff without disturbing your existing app-password session. +This guide walks through a real browser-based OAuth login for GhostOff. -## 1. Create a dedicated OAuth environment file +## 1. Make sure there is no app password configured -Copy your existing `.env` and remove the app password: +GhostOff defaults to OAuth whenever `ATP_APP_PASSWORD` (or `--atproto-app-password`) is absent. The easiest way to test that path is to unset it just for this run: ```bash -cp .env .env.oauth +env ATP_APP_PASSWORD= npm run dev ``` -Edit `.env.oauth` and **delete or comment out** `ATP_APP_PASSWORD`. Keep everything else, especially: +Or temporarily comment out `ATP_APP_PASSWORD` in your `.env`. -```env -GHOST_URL=https://your-ghost-site.com -GHOST_API_KEY=your-ghost-content-api-key -ATP_IDENTIFIER=your-handle-or-did -ATPUBLICATION_AT_URI=at://did:plc:.../site.standard.publication/... -``` +If you want to keep this test isolated from your normal OAuth session files, pass different file names: -You can leave `ATP_SERVICE` set or remove it; OAuth discovers the PDS from the handle, so the field is ignored. +```bash +env ATP_APP_PASSWORD= npm run dev -- \ + --oauth-session-file ghostoff-oauth-session-test.json \ + --oauth-state-file ghostoff-oauth-state-test.json +``` -If you omit `ATP_IDENTIFIER`, the CLI will prompt you for a handle at runtime. +`ATP_SERVICE` can be left set or removed; OAuth discovers the PDS from the handle. -## 2. Run GhostOff with OAuth-only auth +## 2. Answer the prompt -Use isolated session/state files so your default `ghostoff-oauth-session.json` is not affected: +You should see: -```bash -env $(cat .env.oauth | xargs) npm run dev -- \ - --oauth-session-file ghostoff-oauth-session-test.json \ - --oauth-state-file ghostoff-oauth-state-test.json +```text +No app password found in .env or --atproto-app-password. Would you like to authenticate via OAuth [Y/n]? ``` -You can also add `--verbose` for more detail. +Type `Y` and press Enter. + +If `ATP_IDENTIFIER` is not configured, the CLI will then prompt: + +```text +Enter your atproto handle (e.g. handle.example.com): +``` ## 3. Authenticate in the browser @@ -74,7 +77,7 @@ Once the run finishes, check your Offprint publication to confirm the posts appe The atproto local-development exception expects the `client_id` origin to be `http://localhost` and the callback to be `127.0.0.1`. If your PDS insists on `http://127.0.0.1` as the `client_id` origin, override it: ```bash -env $(cat .env.oauth | xargs) npm run dev -- \ +env ATP_APP_PASSWORD= npm run dev -- \ --oauth-client-id "http://127.0.0.1/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto%20transition%3Ageneric" \ --oauth-session-file ghostoff-oauth-session-test.json \ --oauth-state-file ghostoff-oauth-state-test.json @@ -86,7 +89,7 @@ To force a fresh login, delete the test session file and run again: ```bash rm ghostoff-oauth-session-test.json -env $(cat .env.oauth | xargs) npm run dev -- \ +env ATP_APP_PASSWORD= npm run dev -- \ --oauth-session-file ghostoff-oauth-session-test.json \ --oauth-state-file ghostoff-oauth-state-test.json ``` diff --git a/src/atproto.ts b/src/atproto.ts index 51f8a13..a1dc841 100644 --- a/src/atproto.ts +++ b/src/atproto.ts @@ -11,6 +11,7 @@ import type { Config } from './config.js'; import type { BlobRef } from './types.js'; import type { Logger } from './logger.js'; import { + confirmOAuth, createFileSessionStore, createFileStateStore, promptForHandle, @@ -270,9 +271,19 @@ export async function getSession(config: Config, log: Logger): Promise { rl.close(); } } + +export async function confirmOAuth(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question('No app password found in .env or --atproto-app-password. Would you like to authenticate via OAuth [Y/n]? '); + const normalized = answer.trim().toLowerCase(); + return normalized === '' || normalized === 'y' || normalized === 'yes'; + } finally { + rl.close(); + } +} -- 2.51.2 From 253f79a6685e9fae3a4a882bbf48c048c2e0bc38 Mon Sep 17 00:00:00 2001 From: Boris Mann Date: Thu, 2 Jul 2026 16:33:05 -0700 Subject: [PATCH 6/6] Use exact loopback port in OAuth redirect_uris/client_id to satisfy NodeOAuthClient validation --- src/atproto.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/atproto.ts b/src/atproto.ts index a1dc841..2bd4fff 100644 --- a/src/atproto.ts +++ b/src/atproto.ts @@ -29,16 +29,18 @@ export interface AtprotoSession { } const LOCAL_REDIRECT_PATH = '/callback'; -const LOCAL_REDIRECT_URI = 'http://127.0.0.1/callback'; function localRedirectUri(port: number): string { return `http://127.0.0.1:${port}${LOCAL_REDIRECT_PATH}`; } -function buildLocalClientId(scope: string): string { - return `http://localhost/?redirect_uri=${encodeURIComponent(LOCAL_REDIRECT_URI)}&scope=${encodeURIComponent(scope)}`; +function buildLocalClientId(port: number, scope: string): string { + const redirectUri = localRedirectUri(port); + return `http://localhost/?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; } +const noOpLock: import('@atproto/oauth-client').RuntimeLock = async (_key, fn) => fn(); + async function readSessionFile(path: string): Promise { try { const raw = await readFile(path, 'utf-8'); @@ -203,7 +205,11 @@ async function restoreOAuthSession(client: NodeOAuthClient, sessionStorePath: st async function startOAuthFlow(config: Config, log: Logger): Promise { const scope = config.oauthScope; - const clientId = config.oauthClientId || buildLocalClientId(scope); + + // Start the loopback server first so we know the exact port. + const { port, getParams } = await startLoopbackCallbackServer(0); + const redirectUri = localRedirectUri(port); + const clientId = config.oauthClientId || buildLocalClientId(port, scope); const client = new NodeOAuthClient({ clientMetadata: { @@ -212,13 +218,14 @@ async function startOAuthFlow(config: Config, log: Logger): Promise