Recipe: sign-in, connected accounts and social posting on Cloudflare

Let people sign in with an account they already have, connect their social accounts, and post to them now or later from a Worker. Everything here is in @cascivo/app and runs on Workers with plain fetch and WebCrypto: no provider SDKs and no nodejs_compat.

SubpathWhat it does
@cascivo/app/oauthThe OAuth flow and one adapter per provider: GitHub, Google, LinkedIn, Bluesky, Mastodon, Threads, Buffer
@cascivo/app/oauth-serverSign-in (handleOAuth) and connected accounts (handleConnections), tokens sealed in D1
@cascivo/app/socialOne publisher per network: check a post as it is typed, publish it
@cascivo/app/uploadsImages for posts, uploaded through the Worker into R2
ShareMenuA Share button for your readers: each network's own compose link, no account needed

The reference for every function is the @cascivo/app README. This page is the workflow, and the mistakes it is built to prevent.

Pick the job

You wantUseAccounts or tokens?
Readers share a page from their accountShareMenu, or shareIntentUrl in your markupNone
People sign in with GitHub, Google, …handleOAuthDiscarded after sign-in
The app posts for people, now or laterhandleConnections and a publisher per networkKept, sealed in D1
Reach X, Instagram, TikTok and the restbuffer() and bufferPublisherThe user's Buffer

Start from a scaffold

npx cascivo create app --framework cloudflare --auth oauth          # GitHub, Google, LinkedIn sign-in
npx cascivo create app --framework cloudflare --auth email,oauth    # + one-time email links
npx cascivo create social --framework cloudflare --example social   # a post scheduler

--example social connects Bluesky and Mastodon with no set-up at all, and Buffer, LinkedIn and Threads once their app's id and secret are set. It schedules each post as a Workflow and attaches images through R2. Its daily Cron Trigger renews Threads tokens and emails LinkedIn reconnect reminders. The generated README lists every key and the redirect URL to register.

Let readers share

No account, token or third-party script: each entry is the network's own compose link, and the reader posts from their own session there.

import { ShareMenu, shareIntentUrl } from '@cascivo/react'

<ShareMenu url="https://acme.example/launch" text="We launched" />
<a href={shareIntentUrl('bluesky', { url, text }) ?? undefined}>Post to Bluesky</a>

The panel is a native popover, so the links work before hydration. Mastodon has no single host, so the menu asks for the reader's server and remembers it. shareIntentUrl returns null for Mastodon without a usable server.

Sign in with a provider

import { github, google, linkedin } from '@cascivo/app/oauth'
import { handleOAuth } from '@cascivo/app/oauth-server'

const signIn = handleOAuth(env.DB, {
  secret: env.AUTH_SECRET, // at least 32 characters
  providers: [
    github({ clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET }),
    google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),
    linkedin({ clientId: env.LINKEDIN_CLIENT_ID, clientSecret: env.LINKEDIN_CLIENT_SECRET }),
  ],
})

Connect accounts to post with

import { bluesky, linkedin, mastodon, threads } from '@cascivo/app/oauth'
import { handleConnections, mastodonRegistrations } from '@cascivo/app/oauth-server'

const providers = [
  linkedin({ clientId, clientSecret, scopes: ['openid', 'profile', 'w_member_social'] }),
  bluesky({ clientMetadataPath: '/oauth/client-metadata.json' }),
  mastodon({ appName: 'Acme', registrations: mastodonRegistrations(env.DB, env.AUTH_SECRET) }),
  threads({ clientId: env.THREADS_APP_ID, clientSecret: env.THREADS_APP_SECRET }),
]
const answered = await handleConnections(env.DB, { secret: env.AUTH_SECRET, providers })(request)
// GET /api/connections/<provider> connects; Bluesky and Mastodon take ?server=<handle or host>

Tokens are encrypted at rest under AUTH_SECRET, bound to their row. Rotating the secret turns every connection into reconnect, so treat it like a database key.

Post

import { connectionTokens, markReconnect } from '@cascivo/app/oauth-server'
import { blueskyPublisher, PublishError } from '@cascivo/app/social'

const { connection, tokens } = await connectionTokens(env.DB, { secret, providers }, where)
try {
  await blueskyPublisher().publish(
    { tokens, subject: connection.subject, server: connection.server },
    post,
    { idempotencyKey: `${postId}:${connection.id}`, createdAt: dueAt },
  )
} catch (error) {
  if (error instanceof PublishError && error.kind === 'reconnect') {
    await markReconnect(env.DB, connection.id)
  }
  throw error
}

Run publisher.check(post) in the composer as people type, and again in the Worker before anything is stored: a scheduled post fails at once, not at three in the morning.

NetworkLengthImagesSafe to retry?
Bluesky300 graphemes4, 1 MB, uploadedYes: createdAt fixes the record key
MastodonThe server's own (mastodonServerLimits)Usually 4, uploadedYes: Idempotency-Key
LinkedIn3000 charactersUp to 20, uploadedNo
Threads500, an emoji counts its UTF-8 bytesUp to 20, by public URLNo
BufferThe network behind the channel'sUp to 10, by public URLNo

Never retry a network that cannot deduplicate. A request that timed out may already have posted. PublishError.retryable is true only for rate limits and 5xx answers; for LinkedIn, Threads and Buffer, report an interrupted post for a person to check instead.

Schedule

One Cloudflare Workflow per post: it sleeps until the time, then posts to each account in its own step, so one network failing neither blocks nor repeats the others.

Keep connections alive

ProviderWhat keeps it working
Bluesky, Buffer, GoogleconnectionTokens refreshes on use, one request at a time (the refresh token is replaced on every use)
ThreadsThe token renews itself only while it works: a daily refreshConnections
LinkedInCannot be renewed: remind the owner before expiresAt (expiringConnections)
MastodonNothing; a refused token is a reconnect
import { expiringConnections, refreshConnections } from '@cascivo/app/oauth-server'

export default {
  // wrangler.jsonc: "triggers": { "crons": ["17 4 * * *"] }
  async scheduled(_event, env) {
    await refreshConnections(env.DB, { secret: env.AUTH_SECRET, providers })
    for (const { connection, email } of await expiringConnections(env.DB)) {
      // email the owner a link to /api/connections/<provider>, once per token
    }
  },
}

Images

Bluesky, LinkedIn and Mastodon take the bytes. Threads and Buffer fetch images by URL, so they need one they can reach. Upload through the Worker into R2 (@cascivo/app/uploads, each user under their own prefix), and hand those two networks a link to a Worker route signed with an HMAC that expires a day after the post is due. That keeps the bucket private, and needs no S3 API key, unlike an R2 presigned URL. Check the image exists under the user's prefix before you schedule a post with it, and take its type from storage, not from the browser.

Set up each provider

Register https://<your app>/api/connections/<provider>/callback (or /api/auth/oauth/<provider>/callback for sign-in) as the redirect URL.

ProviderWhereBefore strangers can connect
GitHubAn OAuth AppNothing
GoogleAn OAuth client, type "Web application"Verification for sensitive scopes
LinkedInAn app with "Sign In with LinkedIn" and "Share on LinkedIn"Nothing for these two products
BlueskyNothing: your client metadata URL is the client idNothing
MastodonNothing: the app registers itself per serverNothing
ThreadsA Meta app with the Threads use caseApp Review and business verification
BufferAn app client in Buffer's settingsNothing; mind the request budget (100 per 15 minutes)

What this does not cover

As Markdown: /docs/recipe-social.md

← All guides