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.
| Subpath | What it does |
|---|---|
@cascivo/app/oauth | The OAuth flow and one adapter per provider: GitHub, Google, LinkedIn, Bluesky, Mastodon, Threads, Buffer |
@cascivo/app/oauth-server | Sign-in (handleOAuth) and connected accounts (handleConnections), tokens sealed in D1 |
@cascivo/app/social | One publisher per network: check a post as it is typed, publish it |
@cascivo/app/uploads | Images for posts, uploaded through the Worker into R2 |
ShareMenu | A 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 want | Use | Accounts or tokens? |
|---|---|---|
| Readers share a page from their account | ShareMenu, or shareIntentUrl in your markup | None |
| People sign in with GitHub, Google, … | handleOAuth | Discarded after sign-in |
| The app posts for people, now or later | handleConnections and a publisher per network | Kept, sealed in D1 |
| Reach X, Instagram, TikTok and the rest | buffer() and bufferPublisher | The 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 }),
],
})
- Email is optional.
User.emailisstring | null: Bluesky, Mastodon and Threads share none, and GitHub's is used only when it is verified. Check it before you send mail. - Accounts are linked by identity, not by email, except a verified address joins an existing user. An attacker who registers your address at a provider without verifying it gets nothing.
- Sign-in and posting are different grants. Sign in asks for identity only. Ask for the posting scope when the person connects an account to post with, so signing in never shows a "post on your behalf" screen.
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.
| Network | Length | Images | Safe to retry? |
|---|---|---|---|
| Bluesky | 300 graphemes | 4, 1 MB, uploaded | Yes: createdAt fixes the record key |
| Mastodon | The server's own (mastodonServerLimits) | Usually 4, uploaded | Yes: Idempotency-Key |
| 3000 characters | Up to 20, uploaded | No | |
| Threads | 500, an emoji counts its UTF-8 bytes | Up to 20, by public URL | No |
| Buffer | The network behind the channel's | Up to 10, by public URL | No |
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.
- A Workflow refuses to sleep until a past time. Decide "is it due later?" inside a step, so a replay takes the same path, and skip the sleep for "post now".
- Claim each account before calling the network. A step that finds it already claimed was interrupted mid-post: retry it only where the network deduplicates.
- Buffer can hold the post instead. Given a
createdAtahead,bufferPublisherhands the post to Buffer's own queue, where people can still edit it. Cancelling in your app then cannot withdraw it: say so on the page.
Keep connections alive
| Provider | What keeps it working |
|---|---|
| Bluesky, Buffer, Google | connectionTokens refreshes on use, one request at a time (the refresh token is replaced on every use) |
| Threads | The token renews itself only while it works: a daily refreshConnections |
Cannot be renewed: remind the owner before expiresAt (expiringConnections) | |
| Mastodon | Nothing; 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.
| Provider | Where | Before strangers can connect |
|---|---|---|
| GitHub | An OAuth App | Nothing |
| An OAuth client, type "Web application" | Verification for sensitive scopes | |
| An app with "Sign In with LinkedIn" and "Share on LinkedIn" | Nothing for these two products | |
| Bluesky | Nothing: your client metadata URL is the client id | Nothing |
| Mastodon | Nothing: the app registers itself per server | Nothing |
| Threads | A Meta app with the Threads use case | App Review and business verification |
| Buffer | An app client in Buffer's settings | Nothing; mind the request budget (100 per 15 minutes) |
What this does not cover
- Sign in with Bluesky, Mastodon or Threads. The adapters can, but none shares an email, and Threads' review falls on every adopter. Connect them to post; sign in elsewhere.
- X and Instagram directly. Their APIs need paid or reviewed access; Buffer reaches both.
- Video. The publishers post text, links and images.
As Markdown: /docs/recipe-social.md