Facebook delivery for superiorwhiskey.com

Automatic posting of two Superior Whiskey pages a day to the Facebook Page (facebook.com/SuperiorWhiskey). Scope: publishing these posts, nothing else — no advertising, no messaging, no replying to comments, no Page settings.

Modelled on PartingPoint's lib/facebookSpotlights.ts (branch feat/facebook-spotlights), which has been delivering since 2026-09-12. Same doctrine, adapted to a static site.

Why a separate Worker

The website is Eleventy on Cloudflare Pages, and Pages has no scheduled handler — Cloudflare staff, on the Pages-cron question: "Unfortunately not, no. You will need to use Workers for that today." So delivery lives in its own Worker with its own Cron Triggers and its own D1 database. The site's build and deploy pipeline is untouched; the Worker only reads what the site publishes.

That separation also gives a better liveness check than the reference got: the Worker is on a different hostname from the site, so it can fetch the page itself before posting instead of delegating that to CI.

What gets posted, and when

Slot Chicago window Pool
AM 09:00–11:00 scored bottle reviews (63)
PM 17:00–19:00 distillery, type and country hubs, journal pieces, bottle guides, and /about/ /how-we-score/ /reviews/ /for-distilleries/ (64)

The arithmetic

Two posts a day for 90 days needs 180 distinct pages. The site has 127 postable ones. The cooldown is therefore 60 days, decided with Art on 2026-09-13. As the review count grows the cooldown can rise: it is COOLDOWN_DAYS in src/catalog.js, and the safe ceiling is the smaller slot pool (currently 63). Raise it past that and the feed goes quiet.

How a run works

Cron Trigger every 30 min
  ↓  reconcile any `unknown` rows against the Page feed first
  ↓  paused? connected? → stop
  ↓  a due `pending` post (retry) → deliver that, nothing new
  ↓  inside an AM/PM window? → pick that slot's next page
  ↓  slot free today? 6h since the last post? off cooldown? copy legal?
  ↓  GET the page and require its own <link rel="canonical"> → confirmed live
  ↓  INSERT the row (UNIQUE per slot per day, and per page per day)
  ↓  conditional UPDATE … state='pending' — `changes` is the lock
  ↓  POST graph.facebook.com/v25.0/{page-id}/feed

A 200 from the site alone proves nothing — Cloudflare Pages answers unknown paths with the HTML fallback, so the page must declare the exact canonical URL being posted. This was found the hard way on this site: a deleted image path still returned 200 text/html.

Delivery states

state meaning next
pending queued, or waiting for next_attempt_at delivered by the next run (max 5 attempts)
delivering a run holds the row becomes unknown after 10 minutes if the run died
published Facebook returned a post id post_id, permalink, published_at recorded
unknown ambiguous answer (timeout, 5xx, codes 1/2) Page feed read back and matched on exact message; found → published; absent after 15 min → pending
failed Facebook refused, or attempts exhausted operator alert; Retry on the operator page
canceled manual Retry re-queues it

Rate-limit codes (4, 17, 32, 613, 80001) retry in an hour. Token codes (190, 102) flip the channel to needs_reconnect, alert once, and hold the post as pending until the Page is reconnected.

Deliberate difference from PartingPoint: a refused post holds its slot for the day. PartingPoint moves on to the next item; here a second attempt with a different page risks two posts in one slot when the refusal was ambiguous, and never double-posting outranks always posting twice.

Code

src/index.js     Worker entry: scheduled() + routes (operator page, /job, OAuth return)
src/facebook.js  the state machine — eligibility, queue, delivery, reconciliation
src/connect.js   the OAuth dance and the Page token
src/catalog.js   what may be posted; slot pools; cooldown; candidate ordering
src/compose.js   the copy rules
src/crypto.js    AES-GCM token sealing, signed OAuth state and session (WebCrypto only)
src/time.js      Chicago calendar, the two windows, the six-hour gap
src/ui.js        the operator page
test/            34 tests: node --test "test/*.test.mjs"

The catalog itself is built by the website: eleventyConfig.addCollection("socialItems", …) in ../eleventy.config.js, emitted as /social-items.json by ../social-items.njk. A page is postable exactly when this deploy shipped it.

Operator page

The Worker's own URL. Sign in with OPERATOR_PASSPHRASE. Shows the channel status, what each slot would post next, anything held for review, and every post with its exact text, state and permalink. Buttons: Connect / Reconnect Page, Pause / Resume, Check token, Run now, Retry, Remove token.

Setup

There is no Node on the owner's Mac, so this deploys from the Cloudflare dashboard via Workers Builds — Cloudflare clones the repo and runs the build itself. Every push to main redeploys the Worker, the same shape as the website's own pipeline.

  1. D1 → Create Database, name superiorwhiskey-social. Copy its database_id (the UUID in the database's URL) into wrangler.toml and push — a Git-deployed Worker has no dashboard substitute for that value.
  2. Workers & Pages → Create application → Import a repositoryartiomk/superiorwhiskey-site.
  3. [triggers] crons and the D1 binding come from wrangler.toml on deploy — nothing to re-enter in the dashboard.
  4. Settings → Variables and Secrets → Add, type Secret, for each of: FACEBOOK_APP_ID, FACEBOOK_APP_SECRET, FACEBOOK_PAGE_ID, FACEBOOK_LOGIN_CONFIG_ID (optional), SOCIAL_JOB_SECRET (>= 32 random characters), OPERATOR_PASSPHRASE, ALERT_WEBHOOK_URL (optional). Runtime secrets, not build variables — build variables are not visible at run time.

The OAuth redirect URI is derived from the hostname the operator page is served on, so WORKER_ORIGIN only needs setting if the Worker is ever put behind a custom domain.

Meta side: the app must be Live (which requires a public privacy-policy URL) or posts are visible only to app-role users. pages_manage_posts and pages_read_engagement must be added to the use case before the Login for Business configuration can list them. Set the Page's minimum age to 21+ (US) — Meta's alcohol policy.

Then open the operator page → Connect Facebook Page → approve the dialog with an account that can create content on the Page.

Troubleshooting

Known gotchas (carried from PartingPoint, each cost real time there)

Limits