Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsBlogPro
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Authors
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges
  • Chrome Extension
  • Skill Manager

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

ProTermsPrivacyRefunds
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 10/6/2026
developmentjavascripttypescriptpythongojavasqlnodeexpressdebugginggit

Works with

cliapi

Security Analysis

A100/100

Scanned 10/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 automatically 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
Files
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

Kairos-ai-agentKairos-ai-agent
View sourceSee grades on GitHubMore from Kairos-ai-agent →
SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

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 (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Related Skills

Clean Code

Pragmatic coding standards - concise, direct, no over-engineering, no unnecessary comments

304955 votes

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

286712 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2222 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Writing Plans

Use when you have a spec or requirements for a multi-step task, before touching code

2927051 votes
View all in development →