@startup-api/cloudflare
v0.5.0
Published
[](https://opensource.org/licenses/Apache-2.0)
Readme
Startup API Cloudflare App
This application uses the Cloudflare Developer Platform, including Workers and DurableObjects, to implement foundational web application functionality. It acts as a transparent proxy for your application, allowing you to inject custom UI elements and intercept specific paths.
Features
- Transparent Proxying: Forwards requests to your origin application
- HTML Injection: Uses
HTMLRewriterto inject scripts and custom elements (like<power-strip>) into your HTML pages - Path Interception: Intercepts requests to a configurable path to serve internal assets
Installation
Start a new project with the npm create startup-api scaffolder. It generates a tiny Cloudflare Worker that pulls this framework in as the @startup-api/cloudflare npm dependency — so you stay up to date with npm update instead of maintaining a fork of this repository.
npm create startup-api my-app -- --origin https://your-app-origin.com
cd my-app
npm run dev # local dev at http://localhost:8787
npm run deploy # deploy to CloudflareRun npm create startup-api with no arguments to be prompted for the project name and origin URL interactively. Useful flags: --no-install (skip npm install) and --yes / -y (non-interactive — requires a name and --origin).
What you get:
- A minimal
src/index.tsthat re-exports the worker plus awrangler.jsoncyou control. The framework ships as the@startup-api/cloudflaredependency, so your project stays small. - A
.dev.varsfile with a randomSESSION_SECRETfor local development. For production, set your own withnpx wrangler secret put SESSION_SECRET. - Framework updates are just
npm update @startup-api/cloudflare— no fork to rebase.
Then set the required ORIGIN_URL and any OAuth credentials (see Configuration below) and run npm run deploy. See create-startup-api for full details.
Automated deployments
npm run deploy deploys from your machine. To deploy automatically instead, push your scaffolded project to a GitHub repository and use either:
- Cloudflare Workers GitHub app — connect the repo to Cloudflare's Workers Builds Git integration and Cloudflare builds and deploys on every push, no CI config to maintain.
- A GitHub Actions workflow — run
cloudflare/wrangler-actionon push to deploy with Wrangler. Add aCLOUDFLARE_API_TOKEN(andCLOUDFLARE_ACCOUNT_ID) repository secret so the action can authenticate.
Either way, set your production secrets (SESSION_SECRET, OAuth credentials) on the Worker in the Cloudflare dashboard or with npx wrangler secret put rather than committing them.
Working on the framework itself? See CONTRIBUTING.md for cloning and running this repository locally.
Configuration Details
How to set environment variables
Using Cloudflare Dashboard (Recommended):
- Go to Workers & Pages
- Select your worker
- Navigate to Settings > Variables
- Click Add variable under Environment Variables
- Add
ORIGIN_URLand any optional variables - Click Save and deploy
Using
wrangler.jsonc: Add the variables to the"vars"object in your configuration file. See Cloudflare documentation for more details.
| Variable | Required | Default | Description |
| :----------------------- | :------- | :-------- | :---------------------------------------------------------------------------- |
| ORIGIN_URL | Yes | N/A | The base URL of your origin application (e.g., https://your-app-origin.com) |
| USERS_PATH | No | /users/ | The path used to serve internal assets like power-strip.js |
| AUTH_ORIGIN | No | N/A | Optional base URL for OAuth redirects (overrides request origin) |
| GOOGLE_CLIENT_ID | No | N/A | Google OAuth2 Client ID |
| GOOGLE_CLIENT_SECRET | No | N/A | Google OAuth2 Client Secret |
| TWITCH_CLIENT_ID | No | N/A | Twitch OAuth2 Client ID |
| TWITCH_CLIENT_SECRET | No | N/A | Twitch OAuth2 Client Secret |
| PATREON_CLIENT_ID | No | N/A | Patreon OAuth2 Client ID |
| PATREON_CLIENT_SECRET | No | N/A | Patreon OAuth2 Client Secret |
| PATREON_WEBHOOK_SECRET | No | N/A | Secret for verifying Patreon webhook signatures |
| ATPROTO_ENABLED | No | N/A | Set truthy (true/1/yes/on) to enable AT Protocol (Atmosphere) login |
AT Protocol (Atmosphere) login needs no client secret — it is a public OAuth client. Enable it either by setting
ATPROTO_ENABLEDtruthy (a per-deployment switch) or through thecreateStartupAPIfactory (see AT Protocol / Atmosphere below). A factoryatproto: { enabled: false }overrides the env flag, so a deployment can force it off.
Environment variables hold only credentials/secrets (OAuth client IDs and all secrets) plus the per‑deployment values
ORIGIN_URL,AUTH_ORIGIN,USERS_PATH,ADMIN_IDS, andENVIRONMENT. All other configuration — OAuth scopes, Patreon campaign id, the access policy, entitlement freshness — is passed to thecreateStartupAPIfactory (see Access policy & provider entitlements).
Setting up OAuth
- Go to the Google Cloud Console
- Create a new project or select an existing one
- Navigate to APIs & Services > Credentials
- Click Create Credentials > OAuth client ID
- Select Web application as the application type
- Add your authorized redirect URI:
https://<your-worker-url>/users/auth/google/callback - Copy the Client ID and Client Secret and add them to your Worker's environment variables
Twitch
- Go to the Twitch Developer Console
- Register a new application
- Add your authorized redirect URI:
https://<your-worker-url>/users/auth/twitch/callback - Select Website as the category
- Copy the Client ID and generate a Client Secret to add them to your Worker's environment variables
Patreon
- Go to the Patreon Developer Portal
- Click Create Client and fill in your app details
- Add your authorized redirect URI:
https://<your-worker-url>/users/auth/patreon/callback - Copy the Client ID and Client Secret and add them to your Worker's environment variables
AT Protocol / Atmosphere (atproto)
atproto login is decentralized: there is no central provider to register with and no client secret. Instead the worker acts as a public OAuth client identified by a client-metadata document it serves itself, and it discovers the right authorization server per user from their handle or DID — so it works with bsky.social and any self-hosted PDS alike, with no provider host hardcoded.
Because it has no secrets, atproto is enabled in one of two ways:
- Env flag (no code): set
ATPROTO_ENABLEDtruthy (true/1/yes/on) — handy for toggling it per deployment (e.g. on in prod, off in preview) without touching code. This uses the default settings. - Factory config (for customization): include its config key in
createStartupAPI— an empty object is enough, and the optional fields below let you set the client name, resolvers, or scopes.
A factory atproto: { enabled: false } is an explicit opt-out that overrides the env flag, so a deployment can force the provider off.
import { createStartupAPI } from '@startup-api/cloudflare';
const api = createStartupAPI({
providers: {
atproto: {}, // including the key enables it — no client id/secret needed
// All fields below are optional:
// atproto: {
// clientName: 'My App', // shown on the consent screen (default "StartupAPI")
// plcUrl: 'https://plc.directory', // override the PLC directory for did:plc
// dohUrl: 'https://cloudflare-dns.com/dns-query', // override the DoH resolver
// scopes: 'transition:generic', // extra scopes on top of the base `atproto`
// enabled: false, // explicit opt-out (e.g. for dynamically-built config)
// },
},
});
export default api.default;
export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;Enable it — set
ATPROTO_ENABLEDtruthy, or includeatproto: {}in the factoryprovidersconfig (no client id/secret needed either way). A factoryenabled: falseforces it off.Deploy over HTTPS with a stable hostname. The worker automatically serves its client metadata at
https://<your-worker-url>/users/auth/atproto/client-metadata.json(this URL is the OAuthclient_id) and registers the redirect URIhttps://<your-worker-url>/users/auth/atproto/callback.That's it. When a visitor clicks Login with your Atmosphere account, they're asked for their handle (e.g.
alice.bsky.social) or DID; the worker then resolves it through the full atproto discovery chain and redirects them to their own server to sign in:handle ─▶ DID (HTTPS .well-known/atproto-did, then DNS _atproto.<handle> via DoH) DID ─▶ DID document (did:plc via the PLC directory, did:web via the domain) DID doc─▶ PDS endpoint (the #atproto_pds service) PDS ─▶ auth server (.well-known/oauth-protected-resource → oauth-authorization-server)
The flow uses PKCE, DPoP-bound (sender-constrained) tokens, and Pushed Authorization Requests (PAR) as required by the atproto OAuth profile. The PLC directory and DNS-over-HTTPS resolver are generic infrastructure and can be overridden via the plcUrl / dohUrl factory options.
Requesting additional scopes
Each provider requests the minimal scopes needed to sign a user in and read their basic profile. To request more (for example, to read a user's Patreon memberships), set the provider's scopes in the factory config (a string or array). The extra scopes are merged with the required base scopes:
import { createStartupAPI } from '@startup-api/cloudflare';
const api = createStartupAPI({
providers: {
// Adds `identity.memberships` on top of the base `identity identity[email]`
patreon: { scopes: 'identity.memberships' },
},
});Example wrangler.jsonc snippet:
{
"vars": {
"ORIGIN_URL": "https://your-app-origin.com"
}
}How It Works
- Request Interception: The worker receives all incoming requests
- Path Mapping: If the request path starts with
USERS_PATH, the worker serves assets directly from thepublic/users/directory - Proxying: All other requests are proxied to the configured
ORIGIN_URL - Injection: For
text/htmlresponses, the worker injects a<script>tag and a<power-strip>custom element before serving the content to the user
Customizing the power strip
By default the worker injects its own <power-strip> pinned to the top-right corner of the page. If that overlaps your own menu or you simply want it somewhere else, place a <power-strip> element in your own HTML. When the worker sees one, it injects only power-strip.js (which defines the custom element) and leaves your element exactly where you put it — so you control placement and styling:
<nav>
<!-- ...your links... -->
<power-strip></power-strip>
</nav>providersis optional. If you omit it, the worker fills in the active providers for you (e.g.providers="google,twitch,patreon"). Set it yourself to override which login buttons appear.- Prefer an explicit closing tag.
<power-strip></power-strip>and<power-strip/>are both detected, but per the HTML spec<power-strip/>is not truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises. - Script-only opt-out. Use
<power-strip hidden>to loadpower-strip.js(and its JS API) without rendering a visible strip.
Access policy & provider entitlements
StartupAPI can gate access to paths and forward the visitor's login/entitlement status to your origin so it can render gated UI. This is provider-agnostic infrastructure; only Patreon currently implements perk-level (benefit/tier) checks — Google and Twitch participate at the login levels only.
Path-based access policy
Configure an ordered list of rules (first match wins) mapping a path pattern to a requirement, plus a default for unmatched paths. Requirement modes:
bypass— raw pass-through: no credential check, no identity resolution, no headers, no power-strip injection.public— anyone; the session is resolved and identity/entitlement headers are forwarded when present.authenticated— any logged-in user.entitlement— a provider condition: Patreonactive_patron, a specificbenefit(perk) ID, or atierID.
Patterns are exact (/special), prefix (/app/*), or / (homepage only). Each rule's on_unauthorized is login (redirect to sign in), forbidden (403), upgrade (redirect to upgrade_url, e.g. a Patreon join page), or gate (serve an explainer page in place, with no redirect — see below). When no policy is configured at all, every path is treated as public (backward compatible).
Serving a gate page in place (on_unauthorized: 'gate')
Instead of redirecting, a denied request can serve an explainer page at the requested URL (no redirect, status 200 by default). The page shown depends on login state, so anonymous and logged-in-but-unentitled visitors can see different copy:
anonymous(required) — shown to visitors who are not logged in (e.g. a "become a patron + log in" page).unentitled(optional) — shown to logged-in visitors who fail the requirement (e.g. a "pledge/upgrade" page). Falls back toanonymouswhen omitted.status(optional) — HTTP status for the served page; defaults to200to preserve typical explainer-page UX (set403if you prefer).
Each variant is a PageSource whose body comes from either the ASSETS binding or a path proxied from ORIGIN_URL — exactly one of:
{ asset: '/early-access' }— a local file fromASSETS(resolved like other assets,/early-access→early-access.html).{ origin: '/early-access' }— a path proxied fromORIGIN_URL. The path must be reachable directly on the origin (the raw site, not behind this worker).
The gate config is set per rule via gate, or on the policy default via default_gate. The served gate page is produced inside the deny path, so it is not re-subjected to the access policy and no power-strip is injected — the page is expected to carry its own login CTA. Because nothing redirects, there is no loop risk.
const accessPolicy = {
default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
default_on_unauthorized: 'gate',
default_gate: {
anonymous: { origin: '/early-access' }, // or { asset: '/early-access' }
unentitled: { origin: '/pledge-needed' }, // or { asset: '/pledge-needed' }
// status: 403, // optional; defaults to 200
},
};Admin users (those listed in ADMIN_IDS) bypass every authenticated/entitlement requirement and can reach any gated path. Their identity is still resolved and the usual identity/entitlement headers are forwarded to the origin — only the gate itself is skipped. (bypass paths remain a raw pass-through for everyone, with no identity resolution.)
The policy is passed to the factory as accessPolicy (see below). Example:
const accessPolicy = {
rules: [
{ pattern: '/', requirement: { mode: 'public' } },
{
pattern: '/special',
requirement: { mode: 'entitlement', provider: 'patreon', condition: { type: 'benefit', benefit_id: '<BENEFIT_ID>' } },
on_unauthorized: 'upgrade',
upgrade_url: 'https://www.patreon.com/yourpage',
},
],
default: { mode: 'entitlement', provider: 'patreon', condition: { type: 'active_patron' } },
};Headers forwarded to the origin
For non-bypass paths the worker forwards X-StartupAPI-Authenticated, X-StartupAPI-Login-Provider, a compact X-StartupAPI-Entitlements JSON, and (for Patreon) X-StartupAPI-Patreon-Active / -Tiers / -Benefits alongside the existing X-StartupAPI-User-Id / -Account-Id.
Keeping entitlements fresh
Entitlements are fetched at login and then kept fresh per-provider via three mechanisms. Durations below are a named-unit object ({ days: 1 }, { minutes: 15 } — units are summed) or a plain number of milliseconds.
- TTL (
entitlementTtl) — lazily re-check on the request path when the cache is older than the duration, using the OAuth refresh token. On by default for entitlement providers: default 1 day for Patreon (memberships change slowly; this backstops missed webhooks) and 15 min for other providers. Pass a Duration to tune it (entitlementTtl: { hours: 6 }), orentitlementTtl: falseto disable (e.g. rely on the webhook only). - Cron (
entitlementCron: { schedule }) — a scheduled re-sync of all of a provider's credentials. Off by default. Thescheduled()handler is only present when at least one provider enables cron; you must also add a matchingtriggers.cronsto your wrangler config. - Webhook (Patreon only,
entitlementWebhook: true) — off by default. ProvidePATREON_WEBHOOK_SECRET(a secret, in env) and point a Patreon webhook at<your-worker-url>/users/webhooks/patreon(signature verified with HMAC-MD5).
Set providers.patreon.campaignId to disambiguate when a user belongs to multiple campaigns.
Login session lifetime
Login sessions are separate from entitlement freshness: how long a user stays logged in (authentication) is independent of how often their paid status is re-checked (authorization). A user can stay logged in for weeks while their entitlements are refreshed every few minutes via TTL/webhook — and a churned subscriber still loses access within the entitlement window regardless of session validity.
- Default lifetime is 30 days with a rolling window: on activity, once less than half the window remains, the session expiry is pushed forward and the
session_idcookie'sMax-Ageis refreshed. Active users effectively never have to re-login; idle users expire after the window. - The
session_idcookie is a persistent cookie (Max-Age), so it survives browser restarts. - Configure the window via the factory (
session.ttlis a Duration):
const api = createStartupAPI({
session: { ttl: { days: 30 } }, // default; lower/raise as needed (or a number of ms)
});Breaking in 0.5.0: entitlement freshness moved from the
freshness: { ttl, cron, webhook }block to flat per-provider keysentitlementTtl/entitlementCron/entitlementWebhook;entitlementTtlis now on by default (1 day for Patreon) and takes a Duration orfalse. The default session lifetime went from a fixed 24h (no renewal) to a rolling 30 days, andsession_idis now a persistent cookie.
Configuring via the factory
Environment variables hold only credentials/secrets and the per-deployment values (ORIGIN_URL, AUTH_ORIGIN, USERS_PATH, ADMIN_IDS, ENVIRONMENT). Everything else — provider scopes, Patreon campaign id, the access policy, and entitlement freshness — is passed to createStartupAPI(config). The plain re-export still works with defaults:
// Defaults — unchanged:
export { default, UserDO, AccountDO, SystemDO, CredentialDO } from '@startup-api/cloudflare';// Custom configuration:
import { createStartupAPI } from '@startup-api/cloudflare';
const api = createStartupAPI({
providers: {
patreon: {
scopes: 'identity.memberships',
campaignId: '<CAMPAIGN_ID>',
// entitlementTtl is on by default (1 day for Patreon); tune with a Duration or set false to disable.
entitlementTtl: { hours: 12 },
entitlementCron: { schedule: '0 */6 * * *' },
entitlementWebhook: true,
},
},
session: { ttl: { days: 30 } }, // optional; login session lifetime (default 30d, rolling)
accessPolicy: {
rules: [
/* ... */
],
default: { mode: 'public' },
},
});
export default api.default; // includes scheduled() because cron is enabled
export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;(Remember to add triggers.crons to your wrangler config when enabling cron.)
Contributing
Contributions are welcome! See CONTRIBUTING.md for how to clone, run, test, and submit changes to the framework.
License
Licensed under the Apache License, Version 2.0. See LICENSE for details.
