Skip to content
Development

Building a Firefox extension to sync Medium scheduled posts

By Victor Da Luz
firefoxwebextensionsrailsdev-logblog-manager

I’ve been tracking blog posts in blog-manager, a Rails app I built to manage syndication across platforms. I’d just added medium_status and medium_scheduled_at fields (the manual phase) so I could see which posts were queued on Medium. The problem: I had to update them by hand. Every time I scheduled something on Medium, I’d open blog-manager and type in the date manually. Tedious enough that I kept forgetting.

The obvious fix: automate it with a browser extension.

The assumption that was wrong

The issue described intercepting Medium’s GraphQL request for the scheduled-stories list. Medium is a heavy GraphQL app, so this seemed reasonable. I injected a fetch interceptor into the live page to capture GraphQL operations as they fired.

Nothing fired.

Medium server-renders the scheduled-stories list on page load. The data doesn’t come from a network request - it’s already baked into window.__APOLLO_STATE__, the Apollo Client cache that Medium embeds in every page. Seventeen scheduled posts, all sitting there in the global, no network call needed.

This actually made the extension simpler. No webRequest permission, no racing a network response. Just read the data and POST it.

What Apollo state looks like

Each post in window.__APOLLO_STATE__ is keyed as Post:<id>. A scheduled post has isPublished: false and a publishSchedule.publishAt in Unix milliseconds. A draft has publishSchedule: null. Filter is three conditions: __typename === "Post", isPublished === false, publishSchedule?.publishAt truthy.

The extension architecture

Firefox MV3, five files. The content script runs on https://medium.com/me/stories*, reads the Apollo cache, and messages the background script. The background script reads a bearer token from browser.storage.local and POSTs to http://localhost:3000/medium/sync. A small popup lets me paste the shared secret once.

Getting this to work took a few wrong turns.

Wrong turn 1: world: “MAIN”

My first instinct was to run the content script in world: "MAIN" so it could access window.__APOLLO_STATE__ directly. It can - but world: "MAIN" scripts run in the page’s JS context, which means no browser.* extension APIs. browser.runtime.sendMessage throws ReferenceError: browser is not defined.

Firefox 128 added world: "MAIN" support to MV3 content scripts, but the trade-off is losing all WebExtension API access. You can read page globals, but you can’t talk to your background script.

My next attempt was a two-script relay: MAIN world script reads Apollo state and calls window.postMessage, isolated world script listens and relays via browser.runtime.sendMessage. Cleaner on paper. In practice it had issues with the event.source === window check (Firefox isolated world proxies don’t compare equal to the raw page window) and potential timing races between both scripts registering at document_idle.

The actual fix: window.wrappedJSObject

Firefox content scripts use Xray wrappers, which means they get a clean view of the DOM but can’t see page-script-defined globals like window.__APOLLO_STATE__. Firefox-specific workaround: window.wrappedJSObject bypasses the Xray wrapper and gives you the real page window.

const state = window.wrappedJSObject.__APOLLO_STATE__;

One script, isolated world, full browser.* access, reads page globals directly. No relay needed. It’s long-standing Firefox behavior, not tied to any recent release.

Wrong turn 2: CORS

The Rails endpoint needed CORS headers because the content script runs on https://medium.com and POSTs to http://localhost:3000. I added rack-cors configured to allow Origin: https://medium.com.

The extension still failed. The fetch in a background script doesn’t carry the medium.com origin - it comes from moz-extension://.... rack-cors was rejecting it because the origin didn’t match.

Since the endpoint is already protected by a bearer token, the CORS origin restriction adds nothing. Changed to origins "*" for /medium/sync.

Wrong turn 3: Firefox MV3 host permissions

After fixing CORS, the extension still did nothing. Content scripts weren’t injecting. No errors, no logs.

Firefox MV3 changed how host permissions work. In MV2, declaring host_permissions in the manifest automatically granted them. In MV3, they’re opt-in. The user has to explicitly grant access via about:addons → extension → Permissions tab. Until then, content scripts silently don’t run.

For a temporary add-on loaded via about:debugging, this isn’t obvious. There’s no prompt on install. Added a note to the extension README.

The Rails side

Thin controller delegating to Medium::ScheduledImporter, a PORO following the same shape as the existing PostScanner service. It takes an array of { id, title, publish_at } hashes, looks up each by medium_draft_id, and updates medium_status: :scheduled and medium_scheduled_at. Returns counts of matched and missed posts.

Bearer token auth uses ActiveSupport::SecurityUtils.secure_compare to avoid timing attacks. The shared secret lives in config/credentials.yml.enc.

What it looks like working

Visit https://medium.com/me/stories, check the Rails logs: POST /medium/sync 200 with 17 queries fired. Most are misses right now - I haven’t backfilled medium_draft_id on existing posts. As I publish new posts going forward, the ID will be set and the sync will pick them up automatically.

Related reading

Development

The editor commit button is a deploy button

Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.

Read