Building a Firefox extension to sync Medium scheduled posts
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
What happens when a job broadcasts to nobody
Closing the hero-image loop: insert-only frontmatter patching, a guard that caught real drift on its first run, and a Turbo broadcast with no listener.
A doc-drift fix that wasn't as boring as it sounded
Three audit items that each turned into something: a half-fixed claim, a quietly dead password reset, and a staging email that would have linked to production.
A 500 that was hiding inside a mounted engine's isolated routes
The jobs dashboard returned a 500 instead of a login page: bare route helpers resolve against the engine, not the app. One line, plus its dormant twin.
You might also find useful
RackNerd VPS
Budget VPS hosting for lightweight always-on services.
As a RackNerd affiliate, I earn from qualifying purchases.
Learn moreAdGuard for iOS
System-wide ad and tracker blocking on iOS, no separate DNS server required.
As an AdGuard affiliate, I earn from qualifying purchases.
Learn moreNordPass
Password manager from the team behind NordVPN, with a free tier.
As a NordPass affiliate, I earn from qualifying purchases.
Learn more