Skip to content
Back to skills

Cloudflare Worker Fullstack

ASecurity

Build and deploy full-stack web applications on Cloudflare Workers — Workers + D1 + R2 + Pages-style SPA frontend bundled inline. Covers vanilla-JS SPA pattern, D1 schema migrations, custom OAuth (Google/GitHub), custom domain setup, PayPal orders, BYOK image-gen providers, deployment via direct CF REST API (no wrangler CLI), and the token-scope / route gotchas that bite every first deploy.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
developmentjavascripttypescriptpythongojavasqlnodeexpressdebugginggit

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add Kairos-ai-agent/kairos-code --skill cloudflare-worker-fullstack --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Cloudflare Worker Fullstack?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Cloudflare Worker Fullstack
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kairos-ai-agent-cloudflare-worker-fullstack/badge)](https://www.skillsdirectory.com/skills/kairos-ai-agent-cloudflare-worker-fullstack)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: "cloudflare-worker-fullstack"
description: "Build and deploy full-stack web applications on Cloudflare Workers — Workers + D1 + R2 + Pages-style SPA frontend bundled inline. Covers vanilla-JS SPA pattern, D1 schema migrations, custom OAuth (Google/GitHub), custom domain setup, PayPal orders, BYOK image-gen providers, deployment via direct CF REST API (no wrangler CLI), and the token-scope / route gotchas that bite every first deploy."
priority: 0.5
imported-from: "hermes"
source-path: "hermes/skills/.archive/cloudflare-worker-fullstack/SKILL.md"
---
# Cloudflare Worker Full-Stack App

This is the playbook for shipping a production web app entirely on Cloudflare's
edge: Workers (TypeScript/ES module) for logic, D1 (SQLite) for persistence,
R2 for blob storage, and a multi-file vanilla-JS SPA bundled as base64 in the
Worker. No framework runtime, no wrangler CLI required — everything goes
through CF's REST API from a single Python deploy script.

## When to use

- Shipping a real web app (auth, billing, persisted state) that needs to be
  reachable on a real domain, fast, with zero server ops.
- You don't have wrangler installed and don't want to npm-install 200MB of
  tooling just to deploy a static frontend + small API.
- The product is small enough to fit in one Worker (~10MB compressed) and
  reads/writes via D1/R2 only.
- You need real OAuth (Google / GitHub / etc.) plus an end-user API-key flow,
  PayPal orders, and image generation.

## Architecture (one Worker, one D1)

```
yourdomain.com
    │
    ├── GET /           → Worker serves inlined index.html
    ├── GET /css/*.css  → Worker decodes base64 FILES dict → returns text/css
    ├── GET /js/*.js    → same pattern
    └── GET /api/*      → Worker handles (auth, account, orders, generate)
                          → D1 (SQL) / R2 (blob) / outbound HTTP (PayPal, OpenAI)

[deploy.py]  ── base64 ─→  worker_deploy.js (generated)
                ── PUT  ─→   CF API: accounts/{acc}/workers/scripts/{name}
                ── bind ─→   D1, R2, plain_text secrets (GOOGLE_CLIENT_ID, etc.)
                ── PUT  ─→   accounts/{acc}/workers/domains   ← custom domain
```

The single Worker script `worker_deploy.js` contains:
- A generated prefix with `const FILES = { ... }` (base64) and `const ROUTES = { ... }`.
- A `decode(b64)` helper that uses `TextDecoder` (UTF-8 safe for Chinese filenames).
- Your hand-written `worker_api.js` (export default { async fetch(request, env, ctx) }).

The `fetch` handler first checks static routes, then falls through to the
`/api/*` switch. If neither matches, returns 404.

## The deploy.py skeleton (no wrangler needed)

See `templates/deploy_cf.py` — a known-good reference. Key points:

1. Read all files under `css/`, `js/`, `index.html`. Base64-encode and emit
   `const FILES = { "css_style_css": "<b64>", ... }`. The key is `path/to/file`
   with `/` → `_` and `.` → `_` (e.g. `js/views/home.js` → `js_views_home_js`).
2. Emit `const ROUTES = { "/css/style.css": "css_style_css", ... }`.
3. Concatenate the static prefix with `worker_api.js` (skip leading comments
   so you don't double-define `export default`).
4. Validate with `subprocess.run(['node', '--check', out_path])`. **Always do
   this before uploading** — multipart uploads fail silently with HTTP 400 if
   the JS is broken.
5. PUT to `https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/workers/scripts/{SCRIPT_NAME}`
   with multipart/form-data. **`filename` in the part must equal `main_module`
   in metadata** — mismatch causes HTTP 400 with no useful error message.
6. Bindings array contains: d1, plain_text (secrets), r2_bucket. Each
   binding name becomes `env.NAME` in the worker.

### Multipart body shape (the part that always trips people)

```
--BOUNDARY
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{"main_module": "worker_deploy.js", "bindings": [...]}
--BOUNDARY
Content-Disposition: form-data; name="worker_deploy.js"; filename="worker_deploy.js"
Content-Type: application/javascript+module

<worker_js bytes>
--BOUNDARY--
```

`urllib` builds this in ~30 lines. Use a random boundary, build the bytes
yourself, and send as `data=body.encode("utf-8")` with `Content-Type: multipart/form-data; boundary=...`.

## CF API token scopes — read this BEFORE you deploy

CF API tokens have **independent scope bits** for Accounts vs Zones. A token
that can `PUT /accounts/{}/workers/scripts/{}` **cannot** `POST /zones/{}/workers/routes`.
Symptoms: `403 Forbidden` with code 10000 "Authentication error" on zone calls,
200 OK on worker/script calls.

**Two unrelated APIs control the same thing:**

| Want to set | Endpoint | Scope needed |
|-------------|----------|--------------|
| Deploy Worker code | `PUT /accounts/{acc}/workers/scripts/{name}` | Account / Workers Scripts / Edit |
| Bind worker to a custom domain | `PUT /accounts/{acc}/workers/domains` (body: `{hostname, service, zone_id}`) | Account / Workers Scripts / Edit |
| Create a route in a zone (old API) | `POST /zones/{zid}/workers/routes` | **Zone / Workers Routes / Edit** |
| Enable workers.dev subdomain | `POST /accounts/{acc}/workers/scripts/{name}/subdomain` `{enabled: true}` | Account / Workers Scripts / Edit |

If your token only has Account scope, you'll be stuck: workers deploy fine,
workers.dev works, but you cannot touch zone-level routes. Workarounds:

1. **Best**: Create a token with template "Edit Cloudflare Workers" (covers both).
2. **Or**: Bind the custom domain via the Account-level endpoint
   `PUT /accounts/{acc}/workers/domains` (this is what `templates/deploy_cf.py`
   uses). It's idempotent — PUT with same hostname updates `service` field, or
   creates if missing. **Do NOT `DELETE` first** — if you can't recreate
   afterwards (zone routes 403), you've broken your domain with no recovery.
3. As a last resort, ask the user to flip the route manually in Dashboard.

## D1 schema migrations — `CREATE TABLE IF NOT EXISTS` is a trap

D1's `CREATE TABLE IF NOT EXISTS` does NOT alter existing tables. If you
deploy v2 of your schema with `users ADD COLUMN oauth_google TEXT`, the column
will NOT be added if `users` already exists. **You'll get "no such column"
errors at runtime** that look like code bugs but are actually schema drift.

**Idempotent migration pattern** (used in `templates/migrate_d1.py`):

1. Split schema.sql into individual statements.
2. For `ALTER TABLE ... ADD COLUMN` statements, expect `duplicate column`
   errors and treat them as success. D1 query API runs ONE statement per call —
   `multi-statement` is rejected, so you must loop.
3. Verify with `PRAGMA table_info(<table>)` afterwards.

```python
for sql in migrations:
    r = d1_query(sql)
    if not r.get('success'):
        msg = r.get('errors', [{}])[0].get('message', '')
        if 'duplicate column' not in msg.lower():
            print(f'FAIL: {msg}')
```

## Custom OAuth (Google, GitHub) in Workers — the minimal flow

The classic `authorization code` flow, all in the Worker:

```
GET  /api/auth/<provider>          → 302 to provider's /authorize URL
GET  /api/auth/<provider>/callback → exchange code, fetch profile, create user,
                                       issue session token, redirect back
POST /api/auth/logout              → delete session row
GET  /api/auth/me                  → return current user (Bearer token)
```

Key details:

- **State**: Store a random token in KV with `expirationTtl: 600` (10 min) to
  prevent CSRF. Check it in the callback handler, delete after use. If you
  have no KV binding, accept any state of length > 10 (less secure).
- **Secrets in Worker**: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
  `GOOGLE_REDIRECT_URI` (default `https://yourdomain.com/api/auth/<provider>/callback`).
  Pass as `plain_text` bindings.
- **Token issuance**: Insert into `sessions(token, user_id, expires_at)`.
  Return `{token, user}` to client. Client stores in `sessionStorage` and
  sends `Authorization: Bearer <token>` on every API call.
- **User lookup order** for findOrCreateOAuthUser:
  1. Existing user by `oauth_<provider> = profile_id`.
  2. Existing user by `email` — link OAuth to the email-matched account.
  3. Create new user (no signup bonus unless you intentionally set credits > 0).

## Custom image generation providers (BYOK)

Users bring their own OpenAI-compatible API keys. Store them encrypted in D1:

```sql
CREATE TABLE user_api_keys (
  provider TEXT NOT NULL,
  base_url TEXT DEFAULT '',
  model_name TEXT DEFAULT '',
  encrypted_key TEXT NOT NULL,
  UNIQUE(user_id, provider)
);
```

Encryption: AES-GCM with a Worker secret (`AUTH_SECRET`) padded to 32 bytes:

```js
const key = await crypto.subtle.importKey(
  'raw', new TextEncoder().encode(secret.padEnd(32,'0').slice(0,32)),
  { name: 'AES-GCM' }, false, ['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key,
                                       new TextEncoder().encode(plaintext));
// store: base64(iv + ct)
```

Forwarding on generate: `POST {base_url}/images/generations` with body
`{model, prompt, n:1, size, response_format:'url'}`. Parse `{data:[{url}]}`
(or `data:[{b64_json}]`). On failure, refund the credits.

**When image generation fails, the FIRST thing to check is D1's
`generations.error_message`** — it's the only authoritative log of what
the upstream provider actually returned. See
`references/image-generation-failure-diagnosis.md` for the full
4-step ladder.

## Rate limiting in Workers

Sliding window via a `rate_log(user_id, action, created_at)` table. On each
mutating call:

```js
const n = await env.DB.prepare(
  'SELECT COUNT(*) AS n FROM rate_log WHERE user_id=? AND action=? AND created_at>?'
).bind(user.id, action, now()-60).first().n;
if (n >= MAX_PER_MINUTE) return errResp('Rate limited', 429);
await env.DB.prepare('INSERT INTO rate_log ...').bind(...).run();
// periodically clean entries older than 5 min
```

This is cheap, simple, and survives Worker restarts. No KV counter needed.

## PayPal Orders (Checkout)

For one-time purchases, use the v2 Orders API:

```
POST {base}/v1/oauth2/token   → get access_token (Basic auth, grant_type=client_credentials)
POST {base}/v2/checkout/orders → create order, get approval_url
```

base is `https://api-m.sandbox.paypal.com` for sandbox,
`https://api-m.paypal.com` for live. `PAYPAL_ENV` secret controls this.

For `mock` mode (no PayPal creds), return `{mock: true}` from
`createPaypalOrder` so the UI can show a "PayPal not configured" state.

## Vanilla-JS SPA pattern that pairs with this Worker

Files:
- `index.html` — single `<div id="app">` mount point, references 5 JS files.
- `js/i18n.js` — `I18N = { en: {...}, zh: {...} }`, `t(key)` returns
  `I18N[currentLang][key] || key`, `setLang(lang)` re-renders via hashchange.
- `js/store.js` — `apiCall(path, opts)` with auto Bearer header + JSON.
- `js/router.js` — `routes = {}`, `route(path, handler)`, `dispatch()` reads
  `location.hash` and runs the handler.
- `js/app.js` — wires `langToggle.click → toggleLang`, calls `dispatch()` at boot.
- `js/views/<name>.js` — each defines `views.<name> = { async render(params) {...} }`
  and registers via `routeView(path, view, methodName)`.

**The `this` binding pitfall**: if you write `route('/x', views.foo.render)`,
the router will call `handler(params)` with `this === undefined` (strict mode),
so any `this.bindEvents(...)` call inside the view crashes with
"is not a function". Use:

```js
function routeView(path, view, methodName) {
  routes[path] = (...args) => view[methodName].apply(view, args);
}
```

…and register as `routeView('/x', views.foo, 'render')`. Critical for views
that call internal methods via `this`.

**Language toggle re-render**: `setLang` must trigger a full re-render because
views call `t('key')` at render time, baking strings into HTML. Dispatching
a `hashchange` event is the cleanest trigger — router.js already listens to
it. Track `_lastDispatchHash` so re-rendering the same route (language toggle)
doesn't scroll to top.

## Common pitfalls (one-line checklist)

- [ ] `filename` in multipart part == `main_module` in metadata, else HTTP 400.
- [ ] `node --check worker_deploy.js` before PUT, else deploys with broken JS.
- [ ] D1 schema migrations: split statements, tolerate `duplicate column`.
- [ ] CF token scope: Account vs Zone are independent. Custom domains go
      through Account scope (`/accounts/{}/workers/domains`), routes need
      Zone scope (`/zones/{}/workers/routes`).
- [ ] Never DELETE a custom domain record before you can PUT it back. If the
      token can't recreate it, you've orphaned your domain. PUT is idempotent
      and can update OR create — prefer it.
- [ ] `route('/x', view.method)` loses `this` — use `routeView` wrapper.
- [ ] `'}` inside `${cond ? '' : 'value'}` inside a template literal breaks
      parsing — close the string before the expression closes.
- [ ] `try { window.dispatch(...) }` doesn't work if `window.dispatch` was
      never assigned (it's not a DOM method by default). Prefer triggering
      `hashchange` via `dispatchEvent`.
- [ ] Read the `references/` directory for full transcripts of these pitfalls.

## Files in this skill

- `templates/deploy_cf.py` — known-good deploy script (multipart upload + bindings).
- `templates/migrate_d1.py` — idempotent schema migration pattern.
- `templates/worker_api_template.js` — fetch handler skeleton with auth + D1.
- `references/cf-api-endpoints.md` — exhaustive endpoint catalog with token
  scope notes.
- `references/oauth-flow.md` — full Google/GitHub OAuth implementation.
- `references/byok-image-providers.md` — Agnes / OpenAI / Qwen / custom URLs.
- `references/image-generation-failure-diagnosis.md` — 4-step ladder to
  diagnose "image won't generate" via D1 `generations.error_message`,
  OpenAI-compatible provider liveness checks, and the `SF_<KEY>` re-deploy
  pattern.
- `references/pitfalls.md` — debugging notes from past sessions.
- `references/pricing-package-changes.md` — end-to-end workflow for changing
  credit pricing (per-action cost in `PLATFORM_CREDIT_COST` + purchase tiers
  in `pricing_packages`), covering the 3 sources of truth and the
  DELETE+INSERT D1 mutation pattern (because seed uses `INSERT OR IGNORE`).

## See also

- For pure SPA design system (CSS variables, Hero patterns), use the
  `frontend-design` umbrella — this skill assumes you have a CSS file ready.
- For D1 query patterns beyond migrations, see the schema in `templates/`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…