Claude Cursor GitHub Copilot opencode Skill

architecture-and-stack

Cloudflare-first platform selection. Decision trees for Workers, D1, R2, KV, DO, Queues, Vectorize, Containers, Sandboxes, Flagship, Agent Memory, Workflows v2. Default stack, override conditions, auth, data patterns, reliability.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download heymegabyte-claude-skills-05-architecture-and-stack-e7acb91.zip · 83 KB
Part of heymegabyte/claude-skills — 18 skills

Install

skills CLI npx skills add https://github.com/heymegabyte/claude-skills/tree/master/05-architecture-and-stack
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install heymegabyte-claude-skills@llmmart
Git git clone https://github.com/heymegabyte/claude-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole heymegabyte/claude-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

05 — Architecture and Stack

Default stack: _kernel/standards.md#stack. Override conditions below.

Cloudflare-first decision tree

Compute: Workers (default, every HTTP/cron/queue) · Pages (static-only marketing, rare) · Containers (non-JS runtimes: Playwright headful, ffmpeg, Python ML, build orchestration) · Sandbox SDK (generated/risky code before live promotion)

State: D1 (default relational, ≤10GB/db, Sessions API read-replicas, Time Travel 30-day PIT) · KV (eventually-consistent, cache/sessions/feature-flags) · R2 (object storage, lifecycle Standard→IA after 30d) · Durable Objects (coordination + strongly-consistent SQLite storage since Apr 2025, chat rooms/builder sessions/rate-limiting) · Hyperdrive (front external Postgres/MySQL) · Vectorize (semantic search/RAG, 5M dim/index, topK 100, 10 metadata indexes)

Async: Queues (best-effort, 5000 msg/sec, R2 event notifications) · Workflows v2 (deterministic, 50K concurrent, 300 creates/sec, 2M queued/workflow, step.do + step.sleep + step.waitForEvent) · Inngest (event-driven, better DX/observability)

AI: Workers AI (Llama 3.3 70B FP8 free, Llama 3.1 8B FP8, Llama 4 Scout 17B vision) · AI Gateway (caching + rate-limit + fallback + logging for every LLM call) · Vectorize (embeddings + ANN search)

Override conditions (when CF isn't enough)

Need Fallback Adapter
Advanced SQL (RLS, OLAP, partial indexes) Neon Postgres via Hyperdrive SqlPort
Redis primitives at scale (sorted sets, streams) Upstash Redis KvPort
Sub-millisecond global state Upstash QStash QueuePort
Specific provider (OpenAI assistants, Anthropic batch) Direct API via AI Gateway AiPort
Vector + SQL co-located Neon pgvector VectorPort

Adapters live in libs/core/ports/. Product code imports port, never vendor SDK directly. See rules/cloudflare-hostable-supervisor.md.

Auth (default Clerk M2M JWT)

  • Clerk — M2M JWT (free, networkless verification), passkeys, OAuth, magic links; Better Auth when Clerk pricing doesn't fit (rare)
  • Hash API keys at rest. Audit log every sensitive action.
  • Tenant isolation: every table carries org_id, every query filters by it (404 on mismatch, never 403)

Data patterns

D1

[[d1_databases]]
binding = "DB"
database_name = "myapp"
  • wrangler types against compatibility_date + bindings (preferred over hand-maintained Env interface)
  • Drizzle v1 RQBv2 + Zod for query + validation; batch via db.batch([...]) (no transactions in D1)
  • Sessions API: db.withSession(bookmark) · Time Travel: wrangler d1 time-travel restore

R2: per-extension content-type on upload · lifecycle Standard→IA after 30d · event notifications → Queues at 5000 msg/sec for thumbnailing/indexing · versioning for asset rollback

Durable Objects: one DO per stateful entity · SQLite-backed, 10GB per DO · direct stub env.MY_DO.getByName(name) · alarm misfires → idempotent handler

Reliability

  • Workers CPU 10ms free / 50ms paid default (configurable 5min); wall time 30s paid
  • ctx.waitUntil() for async post-response work; ctx.passThroughOnException() for graceful degradation
  • WebSocket + JSRPC payload up to 32 MiB

Cost discipline

  • Workers free tier: 100k req/day; Workers Paid: $5/mo (10M req + 30M CPU-ms) + $0.30/M extra req + $0.02/M extra CPU-ms
  • D1 on Workers Paid: 5GB + 25B rows-read + 50M rows-written/mo; then $0.75/GB-mo + $0.001/M rows-read + $1/M rows-written; no egress; read replication included (verified 2026-06-09)
  • R2: 10GB free, $0.015/GB-mo, $0/egress · Workers AI Llama 3.3 70B FP8 FREE · AI Gateway free
  • Solo SaaS <$100k/mo MRR stays 10-100× cheaper than AWS-equivalent on CF

Default config (wrangler.jsonc)

{
  "name": "myapp",
  "main": "src/worker/index.ts",
  "compatibility_date": "2026-04-15",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  "secrets_required": ["CLERK_SECRET_KEY", "RESEND_API_KEY"],
  "d1_databases": [{ "binding": "DB", "database_name": "myapp" }],
  "kv_namespaces": [{ "binding": "CACHE", "id": "..." }],
  "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "myapp-assets" }],
  "ai": { "binding": "AI" }
}

Decision template (use for every architecture call)

  1. Can CF primitive do this? → Use it.
  2. Does this need adapter for portability? → Adapter only if real business case.
  3. Cost projection at 10× current scale → Still affordable?
  4. Failure mode → Graceful degradation defined?
  5. Migration path → If we have to leave CF, what does it cost?

See submodules: cloudflare-primitives.md, data-patterns.md, reliability.md, auth-patterns.md.

Files (claude-skills)
  • ai-technology-integration.md 6.9 KB
    ---
    name: "AI Technology Integration"
    description: "Latest AI APIs, models, and techniques for building AI-native products. GPT Image 2 vision for visual QA, Workers AI for edge inference, AI Search namespace binding (per-tenant/per-agent RAG), embeddings for RAG, structured outputs, image/video generation, speech, and the visual TDD loop."
    updated: "2026-04-24"
    allowed-tools: "Bash, Read, Write, Edit, mcp__playwright__*"
    ---
    
    # AI Technology Integration
    
    > **Model migration note (pass-78, 2026-06-09)**: `DALL-E` → **GPT Image 1.5** + `GPT-4o` → **GPT Image 2 vision**. Per `platform.openai.com/docs/deprecations`. Visual TDD loop + cost tiers structurally unchanged; verify against current rates.
    
    ## Visual TDD Loop (MANDATORY every deploy — ***COST-TIERED***)
    
    Pipeline:
    
    1. Build → Deploy
    2. a11y tree ALL pages (FREE)
    3. axe-core (FREE) → fix
    4. Screenshot 2bp (375 + 1280)
    5. Workers AI Llama Vision (FREE) → fix
    6. GPT Image 2 vision `detail:low` homepage ATF only ($0.02) → fix
    7. DONE
    
    ### Methods
    
    - **Quick (inline)** — Playwright a11y tree + axe-core (FREE, catches 80%) → Workers AI vision for layout → GPT Image 2 vision `detail:low` for homepage aesthetics only
    - **Automated** — `/Users/apple/.agentskills/scripts/visual-tdd-loop.sh https://example.com 2`
    - **Single image** — `/Users/apple/.agentskills/scripts/gpt4o-vision-analyze.sh screenshot.png`
    
    ### When to run
    
    - Every deploy
    - CSS/layout changes
    - New pages
    - UI PRs
    
    ### Acceptance
    
    - 2 breakpoints (375 + 1280) clean
    - Zero critical/high issues
    - Max 3 iterations
    - ***$1 HARD CAP on GPT Image 2 vision per prompt***
    
    ## Global AI Provider Policy
    
    Use this priority for advanced reasoning and AI-native product work:
    
    1. Anthropic first when available for complex architecture, coding, design, and high-stakes reasoning.
    2. ChatGPT for advanced AI research, decision making, architecture, and web-research-heavy analysis when Anthropic is unavailable or ChatGPT is better suited.
    3. Cloudflare Workers AI / Cloudflare Ollama Workers API for routine summarization, extraction, classification, embeddings, low-stakes transformations, and cost-efficient default work.
    
    Prefer the free Cloudflare option whenever it makes no material difference to result quality. Use the higher-reasoning providers only when the task materially benefits from them, such as complex architecture, website design direction, advanced research, or difficult implementation decisions. Route provider selection through an adapter, log provider and model metadata, and avoid storing raw prompts or raw model outputs unless a project explicitly requires it.
    
    ## Model Selection
    
    | Task | Model | Cost | Latency |
    |------|-------|------|---------|
    | Visual QA (bulk) | Workers AI Llama Vision | FREE | <1s |
    | Visual QA (homepage) | GPT Image 2 vision detail:low | ~$0.02/call | 2-5s |
    | Code gen | Claude Opus 4.6 | Included | - |
    | Logo | Ideogram v3 | ~$0.03 | 5-10s |
    | Hero/scene image | gpt-image-1.5 | ~$0.04 | 10-20s |
    | Hero video (4s) | Sora | ~$0.10 | 30-60s |
    | Alt text | Workers AI (llama-3.2-11b-vision) | Free | <1s |
    | Embeddings | Workers AI (bge-base-en-v1.5) | Free | <100ms |
    | Translation | Workers AI (m2m100-1.2b) | Free | <1s |
    | Summaries | Workers AI (llama-3.1-8b) | Free | <1s |
    | Speech-to-text | Deepgram Nova-2 | $0.0043/min | Real-time |
    | Web search | Firecrawl (self-hosted) | Free | 1-3s |
    
    ### API Keys (in `rare-chefs/.env.local`)
    
    - `OPENAI_API_KEY`
    - `ANTHROPIC_API_KEY`
    - `IDEOGRAM_API_KEY`
    - `REPLICATE_API_TOKEN`
    - `CLOUDFLARE_API_TOKEN`
    
    ## Workers AI Patterns
    
    ```typescript
    // Text: await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [...] });
    // Vision: await env.AI.run('@cf/meta/llama-3.2-11b-vision-instruct', { image: bytes, prompt: '...' });
    // Embeddings: await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: [...] });
    // Translation: await env.AI.run('@cf/meta/m2m100-1.2b', { text, source_lang, target_lang });
    // New models (Apr 2026): GLM, Qwen, EmbeddingGemma (no provider keys), Kimi K2.5 (large agent model), real-time voice
    ```
    
    Wrangler config:
    
    ```toml
    [ai]
    binding = "AI"
    
    [[vectorize]]
    binding = "VECTORIZE_INDEX"
    index_name = "site-content"
    ```
    
    ## AI Search Namespace Binding (Apr 16, 2026)
    
    ```toml
    # wrangler.toml — replaces env.AI.autorag()
    [[ai_search_namespaces]]
    binding = "AI_SEARCH"
    namespace_id = "namespace-id-here"
    ```
    
    ```typescript
    // Query AI Search
    const results = await env.AI_SEARCH.search(query, { topK: 5 });
    // Runtime instance CRUD — per-agent, per-customer, or per-language
    const instance = await env.AI_SEARCH.createInstance({ name: 'tenant-123' });
    // Cross-instance ranked search via instance ID array
    const merged = await env.AI_SEARCH.search(query, { instances: ['tenant-a', 'tenant-b'] });
    ```
    
    - Built-in storage + vector index on new instances
    - Use for: per-tenant RAG, per-agent knowledge bases, multi-language search
    
    ## GPT Image 2 vision Structured Outputs
    
    ```typescript
    response_format: { type: 'json_schema', json_schema: { name: 'visual_qa', schema: {
      properties: { score: { type: 'number' }, issues: { type: 'array', items: { properties: {
        severity: { enum: ['critical','high','medium','low'] }, element: {}, description: {}, fix: {} } } }, summary: {} }
    } } }
    ```
    
    ## Image Generation
    
    - **Logo (Ideogram)** — `"Minimalist logo for [BRAND], cyan (#00E5FF) on black (#060610), clean geometric, no text, vector style"` — V_3, 1:1, DESIGN style
    - **Hero (GPT Image)** — `"Dark atmospheric hero, abstract geometric, cyan light on deep black, premium tech, 21:9"` — gpt-image-1.5, 1536x1024, high
    - **OG (1200x630)** — Generate 1536x1024 then resize with CF Image Resizing
    - **Critique Loop** — Generate → GPT Image 2 vision rate 1-10 → if <8 remix with improved prompt → max 3 iterations
    
    ## RAG Architecture (Cloudflare)
    
    ```typescript
    // 1. Embed query
    const queryEmbed = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: [query] });
    // 2. Search Vectorize
    const results = await env.VECTORIZE_INDEX.query(queryEmbed.data[0].values, { topK: 5, filter: { tenantId } });
    // 3. Build context from chunks
    const context = results.matches.map(m => m.metadata.text).join('\n\n');
    // 4. Generate with context
    await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'system', content: `Answer from:\n${context}` }, { role: 'user', content: query }] });
    ```
    
    ## AI Integration Points
    
    - 07 Quality (visual TDD)
    - 08 Deploy (post-deploy vision)
    - 09 Brand (copy/tone)
    - 10 Design (critique)
    - 12 Media (gen + critique)
    - 14 Idea Engine (research)
    - 07/accessibility-gate (alt text)
    - 09/seo-and-keywords (keywords/meta)
    - 06/site-search (RAG)
    - 06/internationalization (translation)
    - 06/ai-chat-widget (RAG bot)
    
    ## Ownership
    
    - **Owns** — AI model selection, Visual TDD loop, image/video generation, Workers AI patterns, RAG architecture, structured outputs, cost optimization
    - **Never owns** — deployment (→08), testing framework (→07), media pipeline (→12), brand strategy (→09)
    
  • api-design-and-documentation.md 5.2 KB
    ---
    name: "API Design and Documentation"
    description: "Canonical owner of Hono v4.12.x RPC mode (RegExpRouter O(1) matching, 12KB), error envelope format, rate limiting patterns, pagination, OpenAPI spec generation, API versioning, webhook signature verification, and API documentation. Every API endpoint is type-safe, validated, documented, and production-ready. Factory pattern via createFactory() for reusable middleware."
    updated: "2026-04-23"
    ---
    
    # API Design and Documentation
    
    ## Canonical Definitions
    
    ### Error Envelope (ALL error responses)
    
    ```typescript
    interface ErrorResponse { error: string; code?: string; details?: unknown; }
    interface ListResponse<T> { data: T[]; cursor?: string; hasMore: boolean; }
    interface ItemResponse<T> { data: T; }
    ```
    
    ### HTTP Status Codes
    
    - **200** — success GET/PUT/PATCH
    - **201** — created POST
    - **204** — deleted
    - **400** — validation (`VALIDATION_ERROR`)
    - **401** — no auth (`UNAUTHORIZED`)
    - **403** — insufficient perms (`FORBIDDEN`)
    - **404** — not found (`NOT_FOUND`)
    - **409** — conflict (`CONFLICT`)
    - **422** — business logic (`UNPROCESSABLE`)
    - **429** — rate limit (`RATE_LIMITED`)
    - **500** — server error (`INTERNAL_ERROR`)
    
    ### Middleware Order
    
    1. Logger (global)
    2. Security Headers (global)
    3. CORS (`/api/*`)
    4. Rate Limiting (route group)
    5. Auth (route-specific)
    6. Validation (route-specific)
    7. Handler
    
    ## Rules
    
    1. Inline handlers for type inference (Hono RPC requires it)
    2. Export `type AppType = typeof app` for RPC clients via `hc<AppType>`
    3. Zod schema = single source of truth (validate + types + OpenAPI)
    4. Centralized `app.onError()` + `app.notFound()`
    5. Split large APIs — `app.route('/path', subApp)`
    6. Cursor-based pagination (not offset — O(1) vs O(n) on D1)
    7. Version via URL path `/v1/`. Maintain v1 6+ months after v2.
    8. Health endpoint — `GET /health` → `{ status, version, timestamp }`
    9. Rate limits — public 60/min, auth 10/min, webhooks 1000/min, admin 120/min
    10. Every endpoint has Zod schema (body, query, params, response)
    11. API docs generated from code (`@hono/zod-openapi`)
    12. Webhook endpoints verify signatures BEFORE parsing
    
    ## Key Patterns
    
    ### Hono RPC Mode
    
    ```typescript
    const routes = app
      .route('/api/v1/users', usersRoutes)
      .route('/api/v1/posts', postsRoutes)
      .get('/health', (c) => c.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() }));
    export type AppType = typeof routes;
    // Client: const client = hc<AppType>('https://api.domain.com');
    ```
    
    ### Cursor Pagination
    
    ```typescript
    // Fetch limit+1, if results > limit -> hasMore=true, slice off last
    // Cursor = last item's ID (ULID = lexicographically sortable)
    const results = await query.limit(limit + 1);
    const hasMore = results.length > limit;
    const data = hasMore ? results.slice(0, -1) : results;
    return { data, cursor: data[data.length-1]?.id, hasMore };
    ```
    
    ### Centralized Error Handler
    
    ```typescript
    app.onError((err, c) => {
      if (err instanceof HTTPException) return c.json({ error: err.message, code: `HTTP_${err.status}` }, err.status);
      if (err.name === 'ZodError') return c.json({ error: 'Validation failed', code: 'VALIDATION_ERROR', details: err.flatten() }, 400);
      console.error(`[${c.req.method}] ${c.req.path}:`, err);
      return c.json({ error: 'Something went wrong on our end', code: 'INTERNAL_ERROR' }, 500);
    });
    app.notFound((c) => c.json({ error: `Route ${c.req.method} ${c.req.path} not found`, code: 'NOT_FOUND' }, 404));
    ```
    
    ### Webhook Signature Verification
    
    ```typescript
    async function verifyHmacSignature(payload: string, signature: string, secret: string, algorithm = 'sha256'): Promise<boolean> {
      const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: `SHA-256` }, false, ['verify']);
      return crypto.subtle.verify('HMAC', key, hexToBytes(signature), new TextEncoder().encode(payload));
    }
    // Stripe: verify timestamp freshness (<300s) + `${timestamp}.${payload}` signed
    // GitHub: strip 'sha256=' prefix from header
    ```
    
    ### CORS
    
    ```typescript
    app.use('/api/*', cors({
      origin: ['https://domain.com', 'https://www.domain.com'],
      allowMethods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
      allowHeaders: ['Content-Type','Authorization','X-Request-ID'],
      exposeHeaders: ['X-RateLimit-Limit','X-RateLimit-Remaining','X-Request-ID'],
      maxAge: 86400, credentials: true,
    }));
    ```
    
    ### API Versioning
    
    - Mount both — `app.route('/api/v1', v1); app.route('/api/v2', v2);`
    - Add deprecation headers on v1:
      - `Deprecation: true`
      - `Sunset: 2026-12-31`
      - `Link: </api/v2>; rel="successor-version"`
    
    ## Checklist
    
    Every route has:
    
    - Zod validation
    - Error envelope
    - Correct status codes
    - Rate limiting
    - Auth on protected
    - CORS explicit origins
    - Health endpoint
    - Cursor pagination
    - AppType exported
    - Webhook sig verification
    - OpenAPI from Zod
    - Security headers
    - X-Request-ID
    - Cache headers
    
    ## Ownership
    
    - **Owns** — Hono RPC setup, error envelope, middleware layering, rate limiting, pagination, OpenAPI generation, versioning, route organization, CORS, request IDs, webhook verification, API docs, error/404 handlers
    - **Never owns** — DB schema (→44), auth provider (→05), deployment (→08), frontend client UI (→06), business logic, security headers (→22), webhook logic (→45)
    
  • auth-and-session-management.md 6.2 KB
    ---
    name: "Auth and Session Management"
    description: "Clerk Core 3 as the auth layer for all SaaS projects. Middleware patterns for Hono on CF Workers, webhook sync to D1, RBAC with org-scoped roles, protected route patterns, session token handling. Clerk CLI (init/config/api), API Keys GA (machine auth), SCIM/Directory Sync (roadmap, not GA). Covers signup/login flows, user metadata sync, impersonation, and MFA enforcement."
    updated: "2026-04-24"
    ---
    
    # Auth and Session Management (Clerk)
    
    ## Middleware Pattern (Hono + CF Workers)
    
    ```typescript
    // src/middleware/auth.ts
    import { clerkMiddleware, getAuth } from '@clerk/backend';
    
    export const authMiddleware = () => {
      return async (c, next) => {
        const auth = getAuth(c.req.raw, { secretKey: c.env.CLERK_SECRET_KEY });
        const { userId, orgId, orgRole, sessionClaims } = await auth;
        c.set('auth', { userId, orgId, orgRole, sessionClaims });
        await next();
      };
    };
    
    // Protected route helper
    export const requireAuth = () => async (c, next) => {
      const { userId } = c.get('auth');
      if (!userId) return c.json({ error: 'Unauthorized' }, 401);
      await next();
    };
    
    export const requireRole = (role: string) => async (c, next) => {
      const { orgRole } = c.get('auth');
      if (orgRole !== role) return c.json({ error: 'Forbidden' }, 403);
      await next();
    };
    ```
    
    ## Route Protection Layers
    
    - **Public** — `/health`, `/api/webhooks/*`, marketing pages
    - **Auth-only** — `/api/user/*`, `/api/projects/*` (any logged-in user)
    - **Role-gated** — `/api/admin/*` (`org:admin`), `/api/billing/*` (`org:admin|org:billing`)
    - **Owner-only** — `/api/projects/:id/*` (`resource.userId === auth.userId`)
    
    ## Webhook Sync (Clerk → D1)
    
    ```typescript
    // src/routes/webhooks/clerk.ts — handles user.created, user.updated, user.deleted, org.*
    webhooks.post('/clerk', async (c) => {
      const payload = await c.req.json();
      const svix = new Webhook(c.env.CLERK_WEBHOOK_SECRET);
      const evt = svix.verify(await c.req.text(), {
        'svix-id': c.req.header('svix-id')!,
        'svix-timestamp': c.req.header('svix-timestamp')!,
        'svix-signature': c.req.header('svix-signature')!,
      });
    
      switch (evt.type) {
        case 'user.created':
        case 'user.updated':
          await c.env.DB.prepare(
            `INSERT INTO users (id, email, name, avatar_url, updated_at)
             VALUES (?1, ?2, ?3, ?4, ?5)
             ON CONFLICT (id) DO UPDATE SET email=?2, name=?3, avatar_url=?4, updated_at=?5`
          ).bind(evt.data.id, evt.data.email_addresses[0]?.email_address,
            `${evt.data.first_name} ${evt.data.last_name}`.trim(),
            evt.data.image_url, new Date().toISOString()).run();
          break;
        case 'user.deleted':
          await c.env.DB.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')
            .bind(new Date().toISOString(), evt.data.id).run();
          break;
      }
      return c.json({ received: true });
    });
    ```
    
    ## D1 Users Table (Drizzle)
    
    ```typescript
    export const users = sqliteTable('users', {
      id: text('id').primaryKey(),              // Clerk user ID (user_xxx)
      email: text('email').notNull().unique(),
      name: text('name'),
      avatarUrl: text('avatar_url'),
      role: text('role').default('member'),     // App-level roles in D1, NOT Clerk metadata
      stripeCustomerId: text('stripe_customer_id'),
      createdAt: text('created_at').default(sql`(datetime('now'))`),
      updatedAt: text('updated_at').default(sql`(datetime('now'))`),
      deletedAt: text('deleted_at'),
    });
    ```
    
    ## RBAC Pattern
    
    - Roles stored in D1 (not Clerk metadata) for query flexibility
    - Clerk org roles for org-scoped access
    - Pattern — Clerk JWT → extract userId → D1 lookup for app role → authorize
    
    ### Hierarchy
    
    - **Org** — `org:admin` > `org:member` > `org:viewer`
    - **App** — `superadmin` (Brian only) > `admin` > `member` > `viewer`
    
    ## Session Tokens on CF Workers
    
    - Clerk JWT verified per-request (no session store needed)
    - Short-lived tokens (60s) auto-refresh
    - For WebSocket/DO — verify JWT on connect, cache userId in DO state, re-verify on reconnect
    
    ## Frontend (Angular)
    
    ```typescript
    // Clerk Angular SDK: @clerk/clerk-angular (or @clerk/elements for headless)
    // Route guard: inject ClerkService → check isSignedIn$ observable
    // Protect routes: canActivate: [ClerkAuthGuard]
    // Get user: inject ClerkService → user$ observable
    ```
    
    ## Clerk CLI (Apr 22, 2026)
    
    - `clerk init` — framework detect + scaffold (Angular/React/Next/Remix)
    - `clerk config` — auth settings from terminal
    - `clerk api` — direct BAPI access for scripting
    - `clerk deploy` — coming
    - Install — `npm i -g @clerk/cli` or `npx @clerk/cli init`
    
    ## API Keys GA (Apr 17, 2026)
    
    - Machine auth — users create delegated API keys for programmatic access
    - Use for — CI/CD integration, external service auth, customer API access
    - Verify — `clerk.apiKeys.verify(apiKey)`
    - Billing active — counts toward MAU
    
    ## SCIM / Directory Sync (Roadmap — NOT GA)
    
    - On Clerk's roadmap for enterprise orgs — auto user create/update/deactivate from IdP (Okta, Azure AD, OneLogin, Google Workspace)
    - NOT yet generally available as of April 2026
    - When GA — custom attribute mapping into `publicMetadata`, role assignment from IdP groups, no extra charge with enterprise connection
    - For now — use Clerk webhooks (`organizationMembership.created`/`deleted`) for JIT provisioning from IdP
    
    ## Clerk Core 3 (Mar 3, 2026)
    
    - Theme editor for custom auth UI
    - Keyless mode (no `CLERK_PUBLISHABLE_KEY` needed in dev)
    - Modern React compat improvements
    - Upgrade path — `npx @clerk/upgrade` runs codemods automatically
    
    ## Checklist
    
    - [ ] `CLERK_SECRET_KEY` + `CLERK_PUBLISHABLE_KEY` in `wrangler.toml [vars]`
    - [ ] `CLERK_WEBHOOK_SECRET` for svix verification
    - [ ] Webhook endpoint registered in Clerk dashboard (`user.*`, `org.*`, `session.*`, `organizationMembership.*`)
    - [ ] D1 users table with Clerk ID as PK
    - [ ] Middleware applied to all `/api/*` except `/api/webhooks/*` and `/health`
    - [ ] Frontend route guards on protected pages
    - [ ] MFA enforced for admin roles (Clerk dashboard setting)
    - [ ] SCIM directory connection configured for enterprise customers (when GA — currently roadmap)
    - [ ] API Keys enabled for programmatic access use cases
    - [ ] Test — expired JWT returns 401, wrong org returns 403, deleted user returns 401
    
  • auth0-token-vault.md 12.5 KB
    ---
    name: "auth0-token-vault"
    priority: 2
    pack: "architecture"
    triggers:
      - "token vault"
      - "federated token"
      - "CIBA"
      - "agent on behalf of user"
      - "google calendar agent"
      - "user delegation"
      - "async authorization"
      - "human confirmation"
      - "agent acts as user"
      - "AuthorizationPendingInterrupt"
      - "OwnedAgent"
      - "AuthAgent"
    paths:
      - "**/agents/**"
      - "**/wrangler.{toml,jsonc}"
      - "**/agent.ts"
      - "**/server.ts"
    ---
    
    # Auth0 Token Vault + CIBA for Agents
    
    Pattern for AI agents acting on behalf of users — reading their Google Calendar, posting to their Slack, booking their flights — without re-prompting for OAuth consent every time. Tokens are stored in KV per user sub and refreshed automatically. For high-risk actions (stock trades, payments), CIBA (Client-Initiated Backchannel Authentication) suspends the agent stream and waits for user confirmation via push/email before proceeding.
    
    Source: `auth0-lab/cloudflare-agents-starter`. See `[[cf-agents-do-pattern]]`, `[[cloudflare-lock-in-is-leverage]]`.
    
    ## Architecture overview
    
    ```
    Browser → Hono (auth0-hono OIDC) → agentsMiddleware → DO Chat agent
                                              ↓
                                  AuthAgent: parses Bearer + x-refresh-token
                                  OwnedAgent: extracts user sub from JWT
                                  TokenVault: KV → federated access tokens
                                  AsyncUserConfirmationResumer: CIBA alarm loop
    ```
    
    ## Key packages
    
    ```bash
    npm i @auth0/auth0-hono              # OIDC middleware for Hono
    npm i @auth0/auth0-cloudflare-agents-api  # AuthAgent + OwnedAgent DO mixins
    npm i @auth0/ai-cloudflare           # CloudflareKVStore + AsyncUserConfirmationResumer
    npm i @auth0/ai-vercel               # Auth0AI, withTokenVault, withAsyncAuthorization
    npm i hono-agents                    # agentsMiddleware — Hono ↔ CF Agents bridge
    ```
    
    ## wrangler.jsonc bindings
    
    ```jsonc
    {
      "kv_namespaces": [
        {
          "binding": "Session",
          "id": "<kv-namespace-id>"
        },
        {
          "binding": "ChatList",
          "id": "<kv-namespace-id>"
        }
      ],
      "durable_objects": {
        "bindings": [
          { "name": "Chat", "class_name": "Chat" }
        ]
      },
      "migrations": [
        { "tag": "v1", "new_sqlite_classes": ["Chat"] }
      ]
    }
    ```
    
    `Session` KV is dual-purpose: OIDC session cookies + Token Vault federated tokens per user sub.
    
    ## Hono server — OIDC + agent bridge
    
    ```ts
    // src/server.ts
    import { type OIDCVariables, auth, requiresAuth } from '@auth0/auth0-hono';
    import { Hono } from 'hono';
    import { agentsMiddleware } from 'hono-agents';
    
    export type HonoEnv = {
      Bindings: Env;
      Variables: OIDCVariables;
    };
    
    const app = new Hono<HonoEnv>();
    
    // OIDC middleware — handles login/callback/logout/session refresh automatically
    app.use(
      auth({
        domain: process.env.AUTH0_DOMAIN!,
        clientID: process.env.AUTH0_CLIENT_ID!,
        clientSecret: process.env.AUTH0_CLIENT_SECRET!,
        baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
        authorizationParams: {
          audience: process.env.AUTH0_AUDIENCE,
          scope: 'openid profile email',
        },
        session: { secret: process.env.AUTH0_SESSION_ENCRYPTION_KEY! },
        authRequired: false,
        idpLogout: true,
        // Forward extra OAuth params for federated connections (Google Calendar, etc.)
        forwardAuthorizationParams: ['scope', 'access_type', 'prompt', 'connection', 'connection_scope'],
      })
    );
    
    // Route agents — inject auth tokens into the WS/HTTP request before DO handles it
    app.use('/agents/*', requiresAuth('error'), async (c, next) => {
      const session = await c.var.auth0Client?.getSession(c);
      const tokenSet = await c.var.auth0Client?.getAccessToken(c);
    
      // Forward tokens to the DO via headers — AuthAgent reads them
      const addToken = (req: Request) => {
        if (tokenSet?.accessToken) {
          req.headers.set('Authorization', `Bearer ${tokenSet.accessToken}`);
        }
        // x-refresh-token needed ONLY for Token Vault federated connections (e.g. Google Calendar)
        if (session?.refreshToken) {
          req.headers.set('x-refresh-token', session.refreshToken);
        }
        return req;
      };
    
      return agentsMiddleware({
        options: {
          prefix: 'agents',
          onBeforeRequest: addToken,
          onBeforeConnect: addToken,
        },
      })(c, next);
    });
    
    export { Chat } from './agent';
    export default app;
    ```
    
    ## DO agent — mixin stack
    
    ```ts
    // src/agent.ts
    import { AIChatAgent } from 'agents/ai-chat';
    import { AuthAgent, OwnedAgent } from '@auth0/auth0-cloudflare-agents-api';
    import { AsyncUserConfirmationResumer } from '@auth0/ai-cloudflare';
    import { extend } from 'agents';
    
    // Build the mixin stack — order matters:
    // 1. AIChatAgent: base WebSocket chat agent with message persistence
    // 2. AuthAgent: parses Authorization + x-refresh-token headers, exposes getClaims()/getCredentials()
    // 3. OwnedAgent: extracts owner (user sub) from JWT, exposes getOwner()
    // 4. AsyncUserConfirmationResumer: CIBA polling loop via DO alarms
    const SuperAgent = extend(AIChatAgent<Env>)
      .with(AuthAgent)
      .with(OwnedAgent)
      .with(AsyncUserConfirmationResumer)
      .build();
    
    export class Chat extends SuperAgent {
      // Access verified identity in any method:
      async someMethod() {
        const claims = this.getClaims();     // { email, sub, aud, ... }
        const creds = this.getCredentials(); // { access_token, refresh_token, ... }
        const owner = this.getOwner();       // user sub string
      }
    }
    ```
    
    ## Token Vault — federated OAuth tokens in KV
    
    The Token Vault stores OAuth access tokens for third-party services (Google Calendar, Slack, GitHub) keyed by user sub. Tokens are refreshed automatically when expired.
    
    ```ts
    // src/agent.ts
    import { Auth0AI } from '@auth0/ai-vercel';
    import { CloudflareKVStore, getAccessTokenFromTokenVault } from '@auth0/ai-cloudflare';
    
    const auth0AI = new Auth0AI();
    
    // Helper — returns the Token Vault store backed by KV
    function getTokenStore(env: Env) {
      return new CloudflareKVStore({ kv: env.Session });
    }
    
    // Wrap a tool with Token Vault — auto-fetches + refreshes the federated access token
    export const withGoogleCalendar = auth0AI.withTokenVault({
      // Where to get the refresh token for this user
      refreshToken: async () => {
        // getAgent() returns the current DO instance (set up by AgentContext)
        const agent = getAgent<Chat>();
        return agent.getCredentials()?.refresh_token;
      },
      // Which Auth0 federated connection to use
      connection: 'google-oauth2',
      // Scopes required for this tool
      scopes: ['https://www.googleapis.com/auth/calendar.freebusy'],
    });
    
    // Inside a tool definition — no token arg threading needed:
    const checkCalendarAvailability = tool({
      description: 'Check if the user is free on a given date and time',
      inputSchema: z.object({
        date: z.string().describe('ISO 8601 date-time'),
      }),
      // Wrap tool execute with the Token Vault
      execute: withGoogleCalendar(async ({ date }) => {
        // Token is available in async-local-storage — no arg needed
        const accessToken = getAccessTokenFromTokenVault();
    
        const response = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            timeMin: date,
            timeMax: new Date(Date.parse(date) + 3600_000).toISOString(),
            items: [{ id: 'primary' }],
          }),
        });
    
        const data = await response.json<any>();
        return { busy: data.calendars.primary.busy };
      }),
    });
    ```
    
    ## CIBA — human confirmation for high-risk actions
    
    CIBA suspends the agent stream when user approval is required (stock trades, payments, irreversible operations). The DO schedules an alarm to poll for approval; when the user approves via push/email, the alarm fires and the agent resumes.
    
    ```ts
    // src/agent.ts
    import { withAsyncAuthorization, AuthorizationPendingInterrupt } from '@auth0/ai-vercel';
    
    // Wrap a tool with CIBA confirmation
    const tradeTool = withAsyncAuthorization({
      // Who needs to approve
      userID: async () => getAgent<Chat>().getOwner(),
      // What permission is being requested
      scopes: ['stock:trade'],
      // Message shown to user in their phone push notification
      bindingMessage: 'Please confirm the stock trade in the app.',
      // What to do when authorization is pending (agent stream suspended)
      onAuthorizationInterrupt: async (interrupt, context) => {
        // Persist the interrupt + context to DO storage; schedule wakeup alarm
        await getAgent<Chat>().scheduleAsyncUserConfirmationCheck({ interrupt, context });
      },
    })(
      tool({
        description: 'Execute a stock trade',
        inputSchema: z.object({
          ticker: z.string(),
          shares: z.number(),
          action: z.enum(['buy', 'sell']),
        }),
        execute: async ({ ticker, shares, action }) => {
          // Only reached after CIBA approval
          return executeTrade({ ticker, shares, action });
        },
      })
    );
    ```
    
    ### CIBA flow sequence
    
    ```
    1. Agent calls trade tool
    2. withAsyncAuthorization checks if user has pre-authorized 'stock:trade'
    3. No → throws AuthorizationPendingInterrupt
    4. onAuthorizationInterrupt: DO stores interrupt + schedules alarm (30s poll)
    5. Agent stream suspends — client sees "Waiting for your approval..."
    6. Auth0 sends push notification to user's phone
    7. User approves in Auth0 app
    8. DO alarm fires → polls Auth0 CIBA endpoint → approval confirmed
    9. Agent resumes from stored context → trade executes
    10. Agent stream resumes — client sees trade result
    ```
    
    ### DO alarm loop (AsyncUserConfirmationResumer mixin handles this automatically)
    
    ```ts
    // This is what AsyncUserConfirmationResumer does internally — shown for understanding:
    async alarm() {
      const pending = await this.state.storage.get<PendingConfirmation[]>('pendingConfirmations');
      for (const item of pending ?? []) {
        const status = await auth0AI.checkAsyncAuthorizationStatus(item.interrupt);
        if (status === 'approved') {
          // Resume the agent with the stored context
          await this.resumeWithContext(item.context);
        } else if (status === 'denied') {
          await this.sendMessage('Authorization was denied. Operation cancelled.');
        } else {
          // Still pending — reschedule alarm
          await this.state.storage.setAlarm(Date.now() + 30_000);
        }
      }
    }
    ```
    
    ## TokenVaultError → consent flow
    
    When the user has never granted consent for a federated connection, `withTokenVault` throws `TokenVaultError`. Handle it in the frontend:
    
    ```tsx
    // Client-side React — show consent popup when token is missing
    import { TokenVaultConsent } from '@auth0/ai-react';
    
    function ChatUI() {
      return (
        <div>
          <ChatMessages />
          {/* Renders a consent popup when agent throws TokenVaultError */}
          <TokenVaultConsent
            onConsent={() => window.location.href = '/auth/google-calendar'}
          />
        </div>
      );
    }
    ```
    
    ## When to use this vs plain Clerk
    
    | Scenario | Use |
    |---|---|
    | Agent reads/writes user's Google Calendar | **Auth0 Token Vault** |
    | Agent posts to user's Slack workspace | **Auth0 Token Vault** |
    | Agent executes a financial trade on behalf of user | **Auth0 Token Vault + CIBA** |
    | Agent uses YOUR service's API (not user's 3rd-party) | **Clerk** — standard JWT |
    | No agent delegation needed | **Clerk** — simpler, better DX |
    
    ## Gotchas
    
    - **`Session` KV namespace is dual-purpose** — OIDC session cookies AND Token Vault federated tokens share one namespace, keyed differently. Don't delete or TTL-expire KV entries indiscriminately
    - **`x-refresh-token` header only for Token Vault** — the header is only needed when tools use `withTokenVault`. Standard agent tools don't need it; the comment in the source code is clear on this
    - **CIBA requires Auth0 Action** — the push notification / CIBA flow requires configuring an Auth0 Action in the tenant to send notifications. The agent SDK handles the polling; Auth0 handles delivery
    - **`extend(...).with(...).build()` order matters** — `AuthAgent` must come before `OwnedAgent` because `OwnedAgent` depends on the parsed token that `AuthAgent` produces from the header
    - **Local dev** — federated connections won't work in `wrangler dev --local` because Auth0's token endpoints require HTTPS. Use `wrangler dev --remote` for Token Vault testing
    
    ## Cross-links
    
    - `[[cf-agents-do-pattern]]` — base Agent/DO patterns; this mixin stack extends them
    - `[[cf-zero-trust-access]]` — alternative for protecting routes without delegated user tokens
    - `[[cloudflare-lock-in-is-leverage]]` — KV as Token Vault keeps everything on CF edge
    - `[[ai-agent-supervisor]]` — supervisor pattern for orchestrating agents that use Token Vault tools
    
  • background-jobs-and-workflows.md 9.1 KB
    ---
    name: "Background Jobs and Workflows"
    description: "Inngest v4 for durable background jobs on CF Workers. CF Workflows v2 (rearchitected control plane, higher concurrency). Event-driven architecture, scheduled tasks, retry logic, fan-out patterns, and step functions. Covers Stripe webhook processing, email sequences, data sync, D1→R2 backups, long-running workflows, step.ai.infer(), and built-in realtime. Also: CF Cron Triggers (250 paid), CF Queues for high-throughput."
    updated: "2026-04-24"
    ---
    
    # Background Jobs and Workflows
    
    ## Decision Tree
    
    - **Simple schedule** (health check, cache warm) → CF Cron Triggers (5 free, 250 paid)
    - **High-throughput fire-and-forget** (analytics, logs) → CF Queues
    - **Durable multi-step** (onboarding, billing sync, email drip) → Inngest
    - **Stateful long-running** (AI agent, workflow builder) → CF Workflows v2 or DO
    - **D1→R2 daily backup** → CF Workflows + Cron Trigger
    - **Agent orchestration** → CF Agents SDK (Project Think, Fibers for crash-survivable execution)
    
    ## CF Workflows v2 (Apr 15, 2026 — Rearchitected)
    
    - **Concurrency** — 50K concurrent (up from 10K), 300/sec creation rate (up from 30/sec), 2M queued
    - Steps can return `ReadableStream` for >1MiB payloads (no more serialization limit)
    - Control plane fully rearchitected — lower latency, better observability
    - Use for — multi-step pipelines, long-running AI agent tasks, data processing fan-out
    - Inngest still preferred for event-driven durable functions with built-in `step.ai.infer()`
    
    ## Inngest v4 on CF Workers (GA Mar 16, 2026 — BREAKING)
    
    ### v3→v4 Breaking Changes
    
    - Default mode → cloud (was dev). Requires `INNGEST_SIGNING_KEY` or set `isDev: true`/`INNGEST_DEV=1` for local
    - `EventSchemas` removed → use `eventType("name", { schema: z.object({...}) })` per-event
    - Standard Schema support — schema field accepts Zod, Valibot, ArkType, or any Standard Schema lib
    - Triggers moved into options object (1st arg of `createFunction`)
    - Serve options (signingKey, baseUrl) moved to client constructor
    - `step.invoke()` no longer accepts string function IDs
    - `connect()` API — `rewriteGatewayEndpoint` → `gatewayUrl`
    - Middleware completely rewritten — check migration guide
    - Parallel step optimization + checkpointing default-on (~50% fewer HTTP requests)
    
    ### Setup (v4)
    
    ```typescript
    // src/inngest/client.ts
    import { Inngest, eventType } from 'inngest';
    import { z } from 'zod';
    
    // v4: per-event type definitions (replaces EventSchemas)
    const userCreated = eventType('user/created', {
      schema: z.object({ userId: z.string(), email: z.string() }),
    });
    const invoicePaid = eventType('stripe/invoice.paid', {
      schema: z.object({ customerId: z.string(), amount: z.number() }),
    });
    
    export const inngest = new Inngest({
      id: 'my-app',
      eventTypes: [userCreated, invoicePaid],
      // v4: serve options moved here
      // signingKey: env.INNGEST_SIGNING_KEY,
    });
    
    // src/inngest/serve.ts — Hono route (v4: use inngest/cloudflare adapter)
    import { serve } from 'inngest/cloudflare';
    import { inngest } from './client';
    import { functions } from './functions';
    
    app.on(['GET', 'PUT', 'POST'], '/api/inngest', (c) => {
      inngest.setEnvVars(c.env); // v4: runtime bindings for CF Workers
      return serve({ client: inngest, functions })(c.req.raw);
    });
    ```
    
    ### Core Patterns
    
    #### 1. Webhook → Multi-Step Processing
    
    ```typescript
    export const handleStripeInvoice = inngest.createFunction(
      { id: 'stripe-invoice-paid', retries: 3 },
      { event: 'stripe/invoice.paid' },
      async ({ event, step }) => {
        const invoice = await step.run('fetch-invoice', () =>
          stripe.invoices.retrieve(event.data.invoiceId)
        );
        await step.run('update-db', () =>
          db.update(subscriptions).set({ status: 'active', paidAt: new Date().toISOString() })
            .where(eq(subscriptions.stripeCustomerId, invoice.customer))
        );
        await step.run('send-receipt', () =>
          resend.emails.send({ to: invoice.customer_email, template: 'receipt', data: invoice })
        );
        await step.run('track-analytics', () =>
          posthog.capture({ distinctId: event.data.userId, event: 'invoice_paid', properties: { amount: invoice.amount_paid } })
        );
      }
    );
    ```
    
    #### 2. Email Drip Sequence
    
    ```typescript
    export const onboardingDrip = inngest.createFunction(
      { id: 'onboarding-drip' },
      { event: 'user/created' },
      async ({ event, step }) => {
        await step.run('welcome-email', () => sendTemplate('welcome', event.data.email));
        await step.sleep('wait-1d', '1 day');
        await step.run('tips-email', () => sendTemplate('tips', event.data.email));
        await step.sleep('wait-3d', '3 days');
        const user = await step.run('check-activation', () => getUser(event.data.userId));
        if (!user.hasCompletedOnboarding) {
          await step.run('nudge-email', () => sendTemplate('nudge', event.data.email));
        }
      }
    );
    ```
    
    #### 3. Fan-Out (Parallel Steps)
    
    ```typescript
    export const bulkSync = inngest.createFunction(
      { id: 'bulk-sync', concurrency: { limit: 5 } },
      { event: 'data/sync.requested' },
      async ({ event, step }) => {
        const items = await step.run('fetch-items', () => fetchBatch(event.data.cursor));
        // Fan-out: send individual events for each item
        await step.sendEvent('fan-out', items.map(item => ({
          name: 'data/sync.item', data: { itemId: item.id }
        })));
      }
    );
    ```
    
    #### 4. Scheduled (Cron via Inngest)
    
    ```typescript
    export const dailyReport = inngest.createFunction(
      { id: 'daily-report' },
      { cron: '0 9 * * *' }, // 9am UTC daily
      async ({ step }) => {
        const stats = await step.run('gather-stats', () => getDailyStats());
        await step.run('send-report', () =>
          resend.emails.send({ to: 'hey@megabyte.space', subject: 'Daily Report', html: formatReport(stats) })
        );
      }
    );
    ```
    
    #### 5. AI Inference (`step.ai.infer` — v4)
    
    ```typescript
    export const analyzeContent = inngest.createFunction(
      { id: 'analyze-content' },
      { event: 'content/submitted' },
      async ({ event, step }) => {
        // Offloads inference to Inngest infra — pauses function, no serverless compute charges
        const [sentiment, summary] = await Promise.all([
          step.ai.infer('sentiment', { model: 'openai/gpt-4o-mini', body: {
            messages: [{ role: 'user', content: `Classify sentiment: ${event.data.text}` }],
          }}),
          step.ai.infer('summary', { model: 'anthropic/claude-haiku', body: {
            messages: [{ role: 'user', content: `Summarize in 1 sentence: ${event.data.text}` }],
          }}),
        ]);
        await step.run('save', () => db.update(content).set({ sentiment, summary }));
      }
    );
    ```
    
    #### 6. Realtime (built-in v4)
    
    ```typescript
    export const processOrder = inngest.createFunction(
      { id: 'process-order' },
      { event: 'order/placed' },
      async ({ event, step }) => {
        // Durable publish — survives retries
        await step.realtime.publish(`order:${event.data.orderId}`, { status: 'processing' });
        await step.run('charge', () => stripe.charges.create({ amount: event.data.total }));
        await step.realtime.publish(`order:${event.data.orderId}`, { status: 'charged' });
      }
    );
    // Client: import { useRealtime } from '@inngest/realtime/react';
    // const { messages } = useRealtime(`order:${orderId}`);
    ```
    
    ## CF Cron Triggers (Simple Schedules)
    
    ```toml
    # wrangler.toml
    [triggers]
    crons = ["*/5 * * * *", "0 0 * * *"]
    ```
    
    ```typescript
    // src/index.ts — scheduled handler
    export default { fetch: app.fetch, scheduled: async (event, env, ctx) => {
      switch (event.cron) {
        case '*/5 * * * *': await healthCheck(env); break;
        case '0 0 * * *': await dailyCleanup(env); break;
      }
    }};
    ```
    
    ## CF Queues (High-Throughput)
    
    ```toml
    [[queues.producers]]
    queue = "analytics-events"
    binding = "ANALYTICS_QUEUE"
    
    [[queues.consumers]]
    queue = "analytics-events"
    max_batch_size = 100
    max_batch_timeout = 30
    ```
    
    ```typescript
    // Producer: await c.env.ANALYTICS_QUEUE.send({ event: 'page_view', url: path });
    // Consumer: export default { queue: async (batch, env) => { for (const msg of batch.messages) { ... } } };
    ```
    
    ## Patterns
    
    ### Idempotency
    
    - Every Inngest step is retried independently → each step must be idempotent
    - Use D1 dedup table (event_id UNIQUE) for external effects
    - Inngest auto-deduplicates by event ID within 24h
    
    ### Timeout
    
    - `step.sleep()` for delays
    - `step.waitForEvent()` for external triggers (e.g., wait for Stripe webhook before continuing onboarding)
    - Max function duration — 2hrs (Inngest Cloud)
    
    ### Error Handling
    
    - `retries: 3` default, exponential backoff
    - Dead letter — Inngest dashboard
    - Alert — `onFailure` callback → Sentry + Slack
    
    ### v4 `step.ai.infer()`
    
    - Offloads AI inference to Inngest infrastructure — function pauses while inference runs, zero serverless compute charges during wait
    - Parallelizable via `Promise.all()`
    - Supports OpenAI, Anthropic, and custom models
    
    ### v4 Realtime
    
    - `step.realtime.publish(channel, data)` — durable pub/sub (survives retries)
    - `inngest.realtime.publish()` — non-durable fire-and-forget
    - Client — `@inngest/realtime` React hook `useRealtime(channel)`
    - Replaces deprecated `@inngest/realtime` package
    
    ### Testing
    
    - `inngest/test` SDK for local step-through
    - `npx inngest-cli dev` for local dev server with event replay
    - v4 — set `isDev: true` or `INNGEST_DEV=1` for local mode
    
  • cf-2026-updates.md 5.4 KB
    # Cloudflare 2026 Platform Updates — Quick Reference
    
    Pin this alongside the other `05-architecture-and-stack` submodules. Reflects the platform state as of May 2026.
    
    ## Workers Runtime
    
    - **WorkerEntrypoint RPC** — default for service-to-service calls between Workers. Promise pipelining, 32 MiB payload limit, JSRPC compat date `2024-04-03`. Prefer over `fetch()`-over-HTTP service bindings.
    - **Smart Placement** — no longer co-locates with D1 (D1 has global replicas now). Drop any guidance saying "enable Smart Placement when bound to D1."
    - **Workers Builds** — recommended CI for new projects (native, GitHub/GitLab integration, PR checks, rollbacks, pnpm 10 support)
    - **Gradual deployments** + Version Metadata binding — 1% → 10% → 100% traffic splits with `version_id`/`version_tag` access inside the Worker. mTLS bindings compatible.
    - **Workers Automatic Tracing (OTLP)** open beta — `[observability] enabled = true` in `wrangler.jsonc`; free until Mar 1 2026 then billed
    - **WebSocket payload up to 32 MiB** — both Workers and Durable Objects (2025-10-25)
    
    ## D1
    
    - **Read replication GA** via the Sessions API — `db.withSession(bookmark)` for sequentially-consistent reads, no extra cost
    - **Read-only queries auto-retry** (2025-09-11) — remove custom retry wrappers around SELECT/EXPLAIN
    - **Storage cap** — 1 TB per account, 10 GB per database (raised from 250 GB)
    - **Jurisdiction pinning** (2025-11-05) — set EU/FedRAMP at create time for compliance
    - **Time Travel** — 30-day PIT recovery via `wrangler d1 time-travel restore`
    - D1 has **no transactions** — use `db.batch([stmt1, stmt2])` for atomic multi-statement execution
    - **D1 → R2** for long-term backups beyond 30 days
    
    ## R2
    
    - **Infrequent Access storage class** + lifecycle transitions — default lifecycle Standard → IA after 30 days for backups/exports/old uploads
    - **Event notifications → Queues** at 5,000 msg/sec — wire R2 → Queue → consumer Worker for thumbnailing/AV-scan/index instead of polling
    - Cross-region replication available for compliance and latency
    
    ## Durable Objects
    
    - **SQLite-backed DOs GA** (2025-04-07), 10 GB per DO, available on Free plan. Paid storage billing began 2026-01-07.
    - New DOs default to `new_sqlite_classes` not `new_classes`
    - **`DurableObjectNamespace.getByName(name)`** (2025-08-21) — replaces `idFromName` → `get` two-step pattern
    - Alarms remain idempotent — handler must tolerate replay
    
    ## Workflows v2 (2026-05)
    
    - 50,000 concurrent instances (was 4,500), 300 creates/sec, 2M queued per workflow
    - Deterministic step-based execution — `step.do`, `step.sleep`, `step.waitForEvent`, `step.sleepUntil`
    - New default for any agentic or long-running task
    
    ### Decision matrix
    
    - **Workflows v2** — multi-step, deterministic, durable, agentic
    - **Queues** — fan-out, fire-and-forget, R2 event ingestion
    - **DO alarms** — per-entity scheduled work tied to entity state
    - **Cron Triggers** — simple periodic tasks (sweeps, summaries)
    
    ## Vectorize
    
    - 5M dimensions per index (was 200K)
    - topK up to 100 (50 with values/metadata)
    - 10 metadata indexes per index, 10 KiB metadata per vector
    
    ## Hyperdrive
    
    - Now supports **MySQL + Postgres**, free on Workers Free
    - Connection pooling and query caching included at no charge
    - Front any external Postgres/MySQL with Hyperdrive — never direct connection
    
    ## AI Gateway
    
    - `env.AI.run()` auto-routes through AI Gateway when configured
    - Direct Anthropic via `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages`
    - Provides logging, caching, rate-limit, fallback
    - Wire as the 4th observability pillar
    
    ## Containers
    
    - `await env.MY_CONTAINER.fetch()` from a Worker spins a Docker container on demand
    - Escape hatch for non-JS runtimes — Playwright headful, ffmpeg, Python ML, Java services
    - GPU instances "coming soon"
    
    ## Wrangler / Config
    
    - **`wrangler.jsonc`** — new default (not `.toml`). New features ship JSON-only.
    - **`secrets.required`** — config property declares required secrets. Validated at `wrangler dev`/`deploy`/`vite dev`. Feeds `wrangler types`.
    - **`wrangler types`** — supported way to get typed bindings (over `@cloudflare/workers-types`)
    - **Remote bindings** via `remote: true` per binding — routes operations through Miniflare to real prod resources during dev. New default workflow — local code + remote bindings for hard-to-mock services (R2, AI, Vectorize).
    
    ## Clerk + Workers
    
    - **Clerk M2M JWT tokens** (2026-02-24) — free, networkless verification for service-to-service identity
    - `CLERK_JWT_KEY` PEM verification for zero-RTT session checks at the edge
    - Use Clerk M2M instead of static API keys between Workers
    
    ## Drizzle v1 + RQBv2
    
    - **RQBv2** — `with` for nested relations, JIT-compiled row mapper opt-in, single SQL query always
    - `._query` removed for Postgres
    - Pin Drizzle to v1.x
    
    ## Canonical Bindings Stack (saas-starter template)
    
    - `d1_databases` — Sessions API enabled, jurisdiction-pinned if regulated
    - `kv_namespaces` — CACHE (5min default TTL), CONFIG, RATE_LIMIT
    - `r2_buckets` — UPLOADS (IA lifecycle 30d), BACKUPS (IA lifecycle 7d)
    - `ai` — Workers AI gateway
    - `hyperdrive` — for external Postgres/MySQL
    - `vectorize_indexes` — for RAG
    - `queues` — R2 event consumers
    - `durable_objects` — `new_sqlite_classes` for entity state
    - `containers` — non-JS workloads
    - `assets` — Workers Assets binding
    - `secrets` — `secrets.required` block enforces declaration
    - `[observability] enabled = true` — Workers Tracing OTLP
    
  • cf-agents-do-pattern.md 4.9 KB
    ---
    name: "cf-agents-do-pattern"
    priority: 2
    pack: "architecture"
    triggers:
      - "agent"
      - "durable object"
      - "DO"
      - "stateful"
      - "websocket"
      - "RPC"
    paths:
      - "**/wrangler.{toml,jsonc}"
      - "**/durable*"
      - "**/agents/**"
    ---
    
    # Cloudflare Agents on Durable Objects
    
    The canonical pattern for stateful AI agents on Cloudflare: each agent is a Durable Object with built-in SQLite, lazy state hydration, WebSocket broadcasts, and typed RPC via decorators.
    
    Source: `cloudflare/agents` (★5.1k), `cloudflare/agents-starter` (★1.3k). See `[[cloudflare-lock-in-is-leverage]]` — reach for CF primitives directly.
    
    ## Why DO-per-agent
    
    - **Isolation**: each agent has its own SQLite, its own state, its own WebSocket connections
    - **Hibernation**: when idle, DO sleeps with zero cost; SQLite persists between invocations
    - **Multi-tenant trivial**: one agent class = N agent instances, one per tenant/user
    - **No external state store**: no Redis, no Pinecone for agent memory — SQLite covers it
    
    ## Pattern A: Agent class + lazy state
    
    ```ts
    import { Agent, type Connection } from 'agents';
    
    export class OrderAgent extends Agent<Env, { orders: Order[] }> {
      initialState = { orders: [] };
    
      // Lazy hydration — only loads from SQLite on first access
      // State broadcasts to all WebSocket connections on setState()
      async addOrder(order: Order) {
        const orders = [...this.state.orders, order];
        this.setState({ orders }); // broadcasts to all connected clients
      }
    }
    ```
    
    ## Pattern B: `@callable()` decorator — typed RPC over WebSocket, no REST
    
    ```ts
    import { callable } from 'agents';
    
    export class OrderAgent extends Agent<Env, State> {
      @callable()
      async processOrder(orderId: string): Promise<OrderResult> {
        const row = this.sql<Order>`SELECT * FROM orders WHERE id = ${orderId}`;
        return { status: 'shipped', tracking: 'X123' };
      }
    }
    
    // Frontend (any client):
    const result = await agent.processOrder('123'); // fully typed
    ```
    
    No REST layer needed. No Hono routes. Type inference flows from agent class straight to client.
    
    ## Pattern C: `sql`` tagged template — direct SQLite
    
    ```ts
    const rows = this.sql<{ id: string; amount: number }>`
      SELECT id, amount FROM orders
      WHERE tenant_id = ${tenantId} AND status = 'pending'
    `;
    ```
    
    Use `sql\`\`` for **per-agent** state. Use Drizzle + D1 for **shared** relational data. Don't conflate.
    
    ## Pattern D: Three-tier tool execution
    
    ```ts
    const tools = {
      // 1. Server-side auto-execute (server has data + permission)
      getWeather: tool({
        description: 'Get current weather',
        inputSchema: z.object({ city: z.string() }),
        execute: async ({ city }) => ({ temp: 22 }),
      }),
    
      // 2. Client-side (browser computes — no `execute` fn)
      getUserTimezone: tool({
        description: 'Get user timezone',
        inputSchema: z.object({}),
        // browser intercepts, returns Intl.DateTimeFormat().resolvedOptions().timeZone
      }),
    
      // 3. Human-in-the-loop (approval gate)
      calculate: tool({
        description: 'Math operation',
        inputSchema: z.object({ a: z.number(), b: z.number(), operator: z.enum(['add', 'multiply']) }),
        needsApproval: async ({ a, b }) => Math.abs(a) > 1000 || Math.abs(b) > 1000,
        execute: async ({ a, b, operator }) => ({ result: ops[operator](a, b) }),
      }),
    };
    ```
    
    ## Pattern E: `schedule()` — natural language → cron
    
    ```ts
    const scheduleSchema = z.discriminatedUnion('type', [
      z.object({ type: z.literal('scheduled'), date: z.string() }),
      z.object({ type: z.literal('delayed'), delayInSeconds: z.number() }),
      z.object({ type: z.literal('cron'), cron: z.string() }),
      z.object({ type: z.literal('no-schedule') }),
    ]);
    
    // In a tool:
    await this.schedule(input, 'executeTask', description, { idempotent: true });
    ```
    
    ## wrangler.jsonc — critical gotcha
    
    ```jsonc
    {
      "durable_objects": {
        "bindings": [{ "name": "ORDER_AGENT", "class_name": "OrderAgent" }]
      },
      "migrations": [
        { "tag": "v1", "new_sqlite_classes": ["OrderAgent"] }
      ]
    }
    ```
    
    Use `new_sqlite_classes` (NOT `new_classes`) to enable DO built-in SQLite. `new_classes` gives KV-style storage only, no `sql\`\`` template.
    
    ## Anti-patterns (***avoid***)
    
    - ❌ `AIChatAgent` / `@cloudflare/ai-chat` — deprecated in the monorepo, use `Agent<Env, State>` directly
    - ❌ React hooks `useAgent`, `useAgentChat` — your stack is TanStack Router/Start; use `AgentClient` (non-React) for SSR-safe wiring
    - ❌ MCP server mode for internal agent→agent RPC — `@callable()` + typed WS is simpler
    - ❌ Storing agent state in D1 — use `sql\`\`` inside the DO instead; D1 is for shared/relational
    
    ## Cross-link
    
    - `[[cloudflare-lock-in-is-leverage]]` — reach for DO instead of Redis/Pinecone
    - `[[zod-everywhere]]` — every tool inputSchema is Zod
    - `[[ai-agent-supervisor]]` — multi-agent coordination patterns
    - `[[contract-first-ai]]` — typed tool contracts, schema-bound
    - `cf-rag-vectorize-pattern` (this dir) — pair agents with Vectorize for RAG
    - `cf-workflows-pattern` (this dir) — agent calls `workflow.create()` for durable jobs
    
  • cf-auto-provision.md 7.5 KB
    ---
    name: "CF Auto-Provision"
    description: "Single-function project bootstrap via Cloudflare MCP + cf CLI (~3000 API ops). One call provisions D1 database (global read replication), KV namespaces (cache + rate limit), R2 bucket, DNS records, Worker routes, Flagship project, and generates wrangler.jsonc with all bindings. Integrates Clerk, Stripe, PostHog, and Sentry project creation."
    updated: "2026-04-23"
    ---
    
    # CF Auto-Provision
    
    - One function bootstraps an entire project on Cloudflare
    - No manual dashboard clicking
    - Provisions all primitives, generates config, integrates third-party services
    - Use `cf` CLI (unified, ~3000 API ops) or CF MCP (Code Mode, 2 tools + <1K tokens)
    
    ## Function Signature
    
    ```typescript
    interface ProvisionResult {
      d1DatabaseId: string;
      kvCacheId: string;
      kvRateLimitId: string;
      r2BucketName: string;
      workerName: string;
      domain: string;
      wranglerToml: string;
      integrations: {
        clerkAppId?: string;
        stripeProductId?: string;
        posthogProjectId?: string;
        sentryProjectSlug?: string;
      };
    }
    
    async function bootstrapProject(
      domain: string,
      type: 'marketing' | 'saas' | 'api'
    ): Promise<ProvisionResult>
    ```
    
    ## Provision Sequence
    
    ### 1. D1 Database
    
    ```
    MCP: d1_database_create
    Name: {project}-db (e.g. "megabyte-space-db")
    ```
    
    After creation, run initial Drizzle migration with base schema:
    
    ```typescript
    // Base schema varies by type:
    // marketing: contacts, form_submissions, page_views
    // saas: users, subscriptions, teams, invites, audit_log
    // api: api_keys, request_log, rate_limits
    ```
    
    Push schema — `npx drizzle-kit push:sqlite --config=drizzle.config.ts`
    
    ### 2. KV Namespaces
    
    ```
    MCP: kv_namespace_create × 2
    1. {project}-cache   → binding: CACHE (page cache, API responses, TTL-based)
    2. {project}-ratelimit → binding: RATE_LIMIT (per-IP counters, sliding window)
    ```
    
    ### 3. R2 Bucket
    
    ```
    MCP: r2_bucket_create
    Name: {project}-storage
    Binding: STORAGE
    ```
    
    CORS config (set via API after creation):
    
    ```json
    {
      "cors_rules": [{
        "allowed_origins": ["https://{domain}", "https://*.{domain}"],
        "allowed_methods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
        "allowed_headers": ["*"],
        "max_age_seconds": 3600
      }]
    }
    ```
    
    ### 4. DNS Records
    
    ```
    MCP: CF API — POST /zones/{zoneId}/dns_records
    
    For root domain:
      Type: CNAME, Name: {subdomain}, Content: {worker}.workers.dev, Proxied: true
    
    For wildcard (saas type):
      Type: CNAME, Name: *.{subdomain}, Content: {worker}.workers.dev, Proxied: true
    ```
    
    Zone ID lookup — `GET /zones?name={baseDomain}` → extract zone ID
    
    ### 5. Worker Route
    
    ```
    MCP: CF API — POST /zones/{zoneId}/workers/routes
    
    Pattern: {domain}/*
    Script: {project}-worker
    
    For saas type, add: *.{domain}/*
    ```
    
    ### 6. Wrangler.toml Generation
    
    ```jsonc
    // wrangler.jsonc (preferred over .toml since 2025)
    {
      "name": "{project}-worker",
      "main": "src/index.ts",
      "compatibility_date": "2026-04-23",
      "compatibility_flags": ["nodejs_compat"],
      "vars": { "ENVIRONMENT": "production", "VERSION": "0.1.0" },
      "d1_databases": [{ "binding": "DB", "database_name": "{project}-db", "database_id": "{d1-id}" }],
      "kv_namespaces": [
        { "binding": "CACHE", "id": "{cache-kv-id}" },
        { "binding": "RATE_LIMIT", "id": "{ratelimit-kv-id}" }
      ],
      "r2_buckets": [{ "binding": "STORAGE", "bucket_name": "{project}-storage" }],
      "ai": { "binding": "AI" },
      "triggers": { "crons": ["0 */6 * * *"] },
      "assets": { "directory": "./public" }
      // SaaS type adds: durable_objects, queues, vectorize bindings
      // Agent type adds: agents SDK + Agent Memory bindings
    }
    ```
    
    ## Third-Party Integration
    
    ### Clerk (saas type only)
    
    ```bash
    # Via Clerk Dashboard API or CLI
    curl -X POST https://api.clerk.com/v1/applications \
      -H "Authorization: Bearer $CLERK_SECRET_KEY" \
      -d '{"name": "{project}", "allowed_origins": ["https://{domain}"]}'
    ```
    
    Extract `CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` from response. Add to wrangler secrets:
    
    ```bash
    echo "$CLERK_SECRET_KEY" | wrangler secret put CLERK_SECRET_KEY
    echo "$CLERK_PUBLISHABLE_KEY" | wrangler secret put CLERK_PUBLISHABLE_KEY
    ```
    
    ### Stripe
    
    ```bash
    # Via Stripe MCP: create_product
    # Product name: {project title}
    # Then create_price for each tier
    
    # Free tier: $0 (metadata only, no Stripe price)
    # Pro tier: $50/month
    # Enterprise: custom
    ```
    
    Store `STRIPE_API_KEY` and `STRIPE_WEBHOOK_SECRET` as wrangler secrets.
    
    ### PostHog
    
    ```bash
    # Via PostHog API
    curl -X POST https://posthog.megabyte.space/api/projects/ \
      -H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" \
      -d '{"name": "{project}", "timezone": "America/New_York"}'
    ```
    
    Extract project API key. Add to wrangler vars (not secret — it's public):
    
    ```toml
    [vars]
    POSTHOG_API_KEY = "{key}"
    POSTHOG_HOST = "https://posthog.megabyte.space"
    ```
    
    ### Sentry
    
    ```bash
    # Via Sentry API
    curl -X POST https://sentry.megabyte.space/api/0/teams/{org}/{team}/projects/ \
      -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
      -d '{"name": "{project}", "platform": "javascript"}'
    ```
    
    Extract DSN. Add to wrangler vars:
    
    ```toml
    [vars]
    SENTRY_DSN = "{dsn}"
    ```
    
    ## Orchestration
    
    ```typescript
    async function bootstrapProject(
      domain: string,
      type: 'marketing' | 'saas' | 'api'
    ): Promise<ProvisionResult> {
      const project = domain.replace(/\./g, '-');
    
      // Phase 1: Parallel CF provisioning (all independent)
      const [d1, kvCache, kvRate, r2] = await Promise.all([
        cfMcp.d1_database_create({ name: `${project}-db` }),
        cfMcp.kv_namespace_create({ name: `${project}-cache` }),
        cfMcp.kv_namespace_create({ name: `${project}-ratelimit` }),
        cfMcp.r2_bucket_create({ name: `${project}-storage` }),
      ]);
    
      // Phase 2: DNS + routes (depend on zone lookup)
      const zone = await cfApi.getZone(extractBaseDomain(domain));
      await Promise.all([
        cfApi.createDnsRecord(zone.id, { type: 'CNAME', name: domain, content: `${project}-worker.workers.dev`, proxied: true }),
        cfApi.createWorkerRoute(zone.id, { pattern: `${domain}/*`, script: `${project}-worker` }),
      ]);
    
      // Phase 3: Third-party integrations (parallel, type-dependent)
      const integrations: ProvisionResult['integrations'] = {};
      const tasks: Promise<void>[] = [
        createPosthogProject(project).then((id) => { integrations.posthogProjectId = id; }),
        createSentryProject(project).then((slug) => { integrations.sentryProjectSlug = slug; }),
      ];
      if (type === 'saas') {
        tasks.push(
          createClerkApp(project, domain).then((id) => { integrations.clerkAppId = id; }),
          createStripeProduct(project).then((id) => { integrations.stripeProductId = id; }),
        );
      }
      await Promise.all(tasks);
    
      // Phase 4: Generate wrangler.toml
      const wranglerToml = generateWranglerToml({ project, type, d1, kvCache, kvRate, r2, domain });
    
      // Phase 5: Initial Drizzle migration
      await runInitialMigration(type, d1.database_id);
    
      return { d1DatabaseId: d1.database_id, kvCacheId: kvCache.id, kvRateLimitId: kvRate.id, r2BucketName: r2.name, workerName: `${project}-worker`, domain, wranglerToml, integrations };
    }
    ```
    
    ## Type-Specific Defaults
    
    | Resource | marketing | saas | api |
    |----------|-----------|------|-----|
    | D1 tables | contacts, forms, pages | users, subs, teams, audit | api_keys, requests, limits |
    | KV cache | Page HTML, meta | Sessions, feature flags | Response cache |
    | R2 | Images, PDFs | User uploads, exports | Generated files |
    | Durable Objects | No | Realtime rooms | No |
    | Queues | No | Email, webhooks | Webhook processing |
    | Clerk | No | Yes | No |
    | Stripe | No | Yes (3 tiers) | Optional (usage-based) |
    | Cron | Sitemap regen | Usage alerts, cleanup | Key rotation |
    
  • cf-browser-rendering.md 8.5 KB
    ---
    name: "cf-browser-rendering"
    priority: 2
    pack: "architecture"
    triggers:
      - "screenshot"
      - "OG card"
      - "og image"
      - "pdf generation"
      - "headless browser"
      - "web scraping"
      - "puppeteer"
      - "playwright"
      - "browser rendering"
      - "visual qa"
      - "automated screenshot"
    paths:
      - "**/wrangler.{toml,jsonc}"
      - "**/og/**"
      - "**/screenshots/**"
      - "**/pdf/**"
    ---
    
    # CF Browser Rendering
    
    Cloudflare-native headless Chromium. Two integration modes: **REST API** (stateless, zero Worker code) and **Workers Binding** (full Puppeteer API inside a Worker). Both run on CF's edge — no Playwright Cloud, no self-hosted browser farm.
    
    Source: `developers.cloudflare.com/browser-rendering`. See `[[cloudflare-lock-in-is-leverage]]`.
    
    ## Pricing
    
    - Free tier: generous included minutes per month
    - Paid: **$0.09/hr of browser time** — cheaper than Playwright Cloud ($0.40+/hr) and BrowserBase ($0.10+/hr with seat fees)
    - Pay only for active browser time; idle/hibernated sessions cost nothing
    - Available on Free and Paid plans — no Workers Paid plan required for REST API
    
    ## Mode 1 — REST API (no Worker code, stateless)
    
    Best for: OG card generation, one-off screenshots, PDF from URL, CI visual snapshots.
    
    ### Screenshot endpoint
    
    ```bash
    curl -X POST \
      "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-rendering/screenshot" \
      -H "Authorization: Bearer $CF_API_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://megabyte.space/blog/my-post",
        "viewport": { "width": 1200, "height": 630 },
        "screenshotOptions": { "fullPage": false, "type": "png" },
        "gotoOptions": { "waitUntil": "networkidle0", "timeout": 30000 }
      }' \
      --output og-card.png
    ```
    
    ### PDF generation endpoint
    
    ```bash
    curl -X POST \
      "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-rendering/pdf" \
      -H "Authorization: Bearer $CF_API_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://megabyte.space/invoice/123",
        "pdfOptions": {
          "format": "A4",
          "printBackground": true,
          "margin": { "top": "1cm", "bottom": "1cm", "left": "1cm", "right": "1cm" }
        }
      }' \
      --output invoice.pdf
    ```
    
    ### Render HTML → screenshot (OG cards from template)
    
    ```bash
    curl -X POST \
      "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-rendering/screenshot" \
      -H "Authorization: Bearer $CF_API_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "html": "<html><body style=\"margin:0;background:#060610;color:#00E5FF;font-family:Sora,sans-serif;display:flex;align-items:center;justify-content:center;width:1200px;height:630px\"><h1>My Post Title</h1></body></html>",
        "viewport": { "width": 1200, "height": 630 },
        "screenshotOptions": { "type": "png" }
      }' \
      --output og.png
    ```
    
    ### REST API from a Worker (OG card on-demand)
    
    ```ts
    // src/worker/routes/og.ts
    import { Hono } from 'hono';
    
    const app = new Hono<{ Bindings: Env }>();
    
    app.get('/og', async (c) => {
      const title = c.req.query('title') ?? 'Megabyte';
      const html = `<html><body style="margin:0;width:1200px;height:630px;
        background:#060610;color:#00E5FF;font-family:Sora,sans-serif;
        display:flex;align-items:center;padding:80px">
        <h1 style="font-size:72px;line-height:1.1">${title}</h1>
      </body></html>`;
    
      const res = await fetch(
        `https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/browser-rendering/screenshot`,
        {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${c.env.CF_API_TOKEN}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            html,
            viewport: { width: 1200, height: 630 },
            screenshotOptions: { type: 'png' },
          }),
        }
      );
    
      return new Response(res.body, {
        headers: {
          'Content-Type': 'image/png',
          'Cache-Control': 'public, max-age=86400, s-maxage=604800',
        },
      });
    });
    
    export default app;
    ```
    
    ## Mode 2 — Workers Binding (Puppeteer API)
    
    Best for: scraping authenticated pages, multi-step flows, full-page interactions, session reuse.
    
    ### wrangler.toml
    
    ```toml
    [browser]
    binding = "MYBROWSER"
    ```
    
    No other config needed — CF provisions the headless Chromium instance automatically.
    
    ### Basic Worker (screenshot + close)
    
    ```ts
    import puppeteer from '@cloudflare/puppeteer';
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const browser = await puppeteer.launch(env.MYBROWSER);
        const page = await browser.newPage();
        await page.setViewport({ width: 1200, height: 630 });
        await page.goto('https://example.com', { waitUntil: 'networkidle0' });
    
        const screenshot = await page.screenshot({ type: 'png', fullPage: false });
        await browser.close();
    
        return new Response(screenshot, { headers: { 'Content-Type': 'image/png' } });
      },
    };
    ```
    
    ### Session reuse pattern (critical for performance)
    
    Omit `browser.close()` to keep the session warm. CF closes it after 1 minute of inactivity by default. Use `keep_alive` to extend to 10 minutes.
    
    ```ts
    import puppeteer, { Browser } from '@cloudflare/puppeteer';
    
    // DO-backed session reuse (one browser per DO instance)
    export class BrowserSession {
      private browser: Browser | null = null;
      state: DurableObjectState;
      env: Env;
    
      constructor(state: DurableObjectState, env: Env) {
        this.state = state;
        this.env = env;
      }
    
      async getBrowser(): Promise<Browser> {
        if (this.browser) {
          try {
            // Check if still alive
            await this.browser.version();
            return this.browser;
          } catch {
            this.browser = null;
          }
        }
        this.browser = await puppeteer.launch(this.env.MYBROWSER, {
          keep_alive: 600_000, // 10 minutes in ms
        });
        return this.browser;
      }
    
      async fetch(request: Request): Promise<Response> {
        const browser = await this.getBrowser();
        const page = await browser.newPage();
        await page.goto(new URL(request.url).searchParams.get('url') ?? 'about:blank');
        const html = await page.content();
        await page.close(); // close page, NOT browser — keeps session alive
        return new Response(html);
      }
    }
    ```
    
    ### PDF generation via binding
    
    ```ts
    const page = await browser.newPage();
    await page.goto('https://megabyte.space/invoice/123', { waitUntil: 'networkidle0' });
    const pdf = await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' },
    });
    await browser.close();
    return new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } });
    ```
    
    ### Scraping with selector targeting
    
    ```ts
    await page.goto('https://example.com/data');
    await page.waitForSelector('.price-table');
    const data = await page.evaluate(() => {
      // Note: can only return primitives (string, number, boolean) or JSON-serializable objects
      return Array.from(document.querySelectorAll('.price-row')).map((row) => ({
        name: row.querySelector('.name')?.textContent?.trim(),
        price: row.querySelector('.price')?.textContent?.trim(),
      }));
    });
    ```
    
    ## Use cases
    
    | Use case | Mode | Notes |
    |---|---|---|
    | OG card generation | REST API | Render HTML template, return PNG, cache at CDN |
    | Invoice PDF | REST API | Pass URL + cookies for auth |
    | Visual QA snapshots | REST API | CI: capture + diff against baseline |
    | Authenticated scraping | Workers Binding | Inject cookies, navigate multi-step |
    | Web crawling | Workers Binding | Session reuse across pages — open page, close page, keep browser |
    | Stagehand AI scraping | Workers Binding | AI-driven element selection by intent |
    
    ## Gotchas
    
    - **XPath not supported** — use CSS selectors only; XPath raises a security error at runtime
    - **`userAgent` does not bypass bot protection** — sites with Cloudflare Bot Management will still block headless
    - **`page.evaluate()` primitives only** — return strings/numbers/booleans or JSON-serializable objects; DOM nodes, functions, and class instances cannot cross the boundary
    - **One browser per Worker invocation** — each `puppeteer.launch()` consumes a concurrent browser slot; use DO session reuse for high-throughput workloads
    - **Session limit** — default 2 concurrent browser sessions per account on free tier; contact CF for higher limits on paid plans
    - **`browser.close()` vs `page.close()`** — close pages between requests, keep browser open for session reuse; browser closes itself after `keep_alive` ms of inactivity
    
    ## Cross-links
    
    - `[[cf-do-rate-limiter]]` — wrap the OG endpoint with DO rate limiter to prevent abuse
    - `[[cloudflare-lock-in-is-leverage]]` — CF Browser Rendering vs self-hosted Playwright
    - `[[ai-agent-supervisor]]` — agents can trigger screenshot tasks as tool calls
    
  • cf-do-rate-limiter.md 10.9 KB
    ---
    name: "cf-do-rate-limiter"
    priority: 2
    pack: "architecture"
    triggers:
      - "rate limit"
      - "rate limiter"
      - "sliding window"
      - "throttle"
      - "too many requests"
      - "429"
      - "abuse prevention"
      - "API rate"
      - "per-user limit"
    paths:
      - "**/wrangler.{toml,jsonc}"
      - "**/middleware*"
      - "**/durable-objects/**"
      - "**/rate-limit*"
    ---
    
    # CF DO Sliding-Window Rate Limiter
    
    Production-grade per-user/per-IP rate limiter using Durable Objects. Each user gets their own DO shard — no global lock contention. Sliding window algorithm smooths burst spikes better than fixed windows. Cache API short-circuits blocked responses without hitting the DO.
    
    Source: `OultimoCoder/cloudflare-planetscale-hono-boilerplate` (real implementation, not pseudocode). See `[[cloudflare-lock-in-is-leverage]]`.
    
    ## Algorithm
    
    ```
    rate = (prevCount × (interval − distanceFromLastWindow)) / interval + currentCount
    blocked = rate ≥ limit
    ```
    
    Sliding window interpolates between the previous and current fixed windows, weighted by how far into the current window you are. This prevents the "double burst" problem of fixed windows (100 req at 11:59 + 100 req at 12:01 = 200 in 2 minutes with a 60s fixed window).
    
    Composite storage key: `scope|key|limit|interval|windowTimestamp`
    
    ## wrangler.toml
    
    ```toml
    [[durable_objects.bindings]]
    name = "RATE_LIMITER"
    class_name = "RateLimiter"
    
    [[migrations]]
    tag = "v1"
    new_classes = ["RateLimiter"]
    ```
    
    ## Full DO class
    
    ```ts
    // src/durable-objects/rate-limiter.do.ts
    import dayjs from 'dayjs';
    import { Context, Hono } from 'hono';
    import { z, ZodError } from 'zod';
    import { fromError } from 'zod-validation-error';
    
    interface Config {
      scope: string;    // endpoint path — e.g. '/api/v1/send-email'
      key: string;      // user sub or IP
      limit: number;    // max requests per interval
      interval: number; // window size in seconds
    }
    
    const configSchema = z.object({
      scope: z.string(),
      key: z.string(),
      limit: z.number().int().positive(),
      interval: z.number().int().positive(),
    });
    
    export class RateLimiter {
      state: DurableObjectState;
      env: Env;
      app: Hono = new Hono();
    
      constructor(state: DurableObjectState, env: Env) {
        this.state = state;
        this.env = env;
    
        this.app.post('/', async (c) => {
          await this.setAlarm();
    
          let config: Config;
          try {
            config = configSchema.parse(await c.req.json());
          } catch (err) {
            const msg = err instanceof ZodError ? fromError(err).toString() : String(err);
            return c.json({ error: msg }, 400);
          }
    
          const rate = await this.calculateRate(config);
          const blocked = this.isRateLimited(rate, config.limit);
          const headers = this.getHeaders(blocked, config);
          const remaining = blocked ? 0 : Math.max(0, Math.floor(config.limit - rate - 1));
    
          return c.json({ blocked, remaining, expires: headers.expires }, 200, headers);
        });
      }
    
      // Alarm fires every 6h — purge keys older than 2 intervals to bound storage growth
      async alarm() {
        const values = await this.state.storage.list<number>();
        const now = this.nowUnix();
        for (const [key] of values) {
          const parts = key.split('|');
          const interval = parseInt(parts[3]);
          const timestamp = parseInt(parts[4]);
          const currentWindow = Math.floor(now / interval);
          if (timestamp < currentWindow - 2) {
            await this.state.storage.delete(key);
          }
        }
      }
    
      async setAlarm() {
        const alarm = await this.state.storage.getAlarm();
        if (!alarm) {
          await this.state.storage.setAlarm(dayjs().add(6, 'hours').toDate());
        }
      }
    
      nowUnix(): number {
        return dayjs().unix();
      }
    
      async calculateRate(config: Config): Promise<number> {
        const keyPrefix = `${config.scope}|${config.key}|${config.limit}|${config.interval}`;
        const now = this.nowUnix();
        const currentWindow = Math.floor(now / config.interval);
        const distanceFromLastWindow = now % config.interval;
    
        const currentKey = `${keyPrefix}|${currentWindow}`;
        const previousKey = `${keyPrefix}|${currentWindow - 1}`;
    
        const currentCount = await this.getCount(currentKey);
        const previousCount = await this.getCount(previousKey);
    
        // Sliding window formula
        const rate =
          (previousCount * (config.interval - distanceFromLastWindow)) / config.interval +
          currentCount;
    
        if (!this.isRateLimited(rate, config.limit)) {
          await this.state.storage.put(currentKey, currentCount + 1);
        }
    
        return rate;
      }
    
      async getCount(key: string): Promise<number> {
        return ((await this.state.storage.get<number>(key)) ?? 0);
      }
    
      isRateLimited(rate: number, limit: number): boolean {
        return rate >= limit;
      }
    
      getHeaders(blocked: boolean, config: Config) {
        const expirySeconds = this.expirySeconds(config);
        const retryAfter = dayjs().add(expirySeconds, 'seconds').toString();
        const headers: Record<string, string> = { expires: retryAfter };
        if (blocked) {
          headers['cache-control'] =
            `public, max-age=${expirySeconds}, s-maxage=${expirySeconds}, must-revalidate`;
        }
        return headers;
      }
    
      expirySeconds(config: Config): number {
        const now = this.nowUnix();
        const currentWindowStart = Math.floor(now / config.interval);
        return (currentWindowStart + 1) * config.interval - now;
      }
    
      async fetch(request: Request): Promise<Response> {
        return this.app.fetch(request);
      }
    }
    ```
    
    ## Hono middleware
    
    ```ts
    // src/middlewares/rate-limiter.ts
    import dayjs from 'dayjs';
    import { type MiddlewareHandler } from 'hono';
    import { HTTPException } from 'hono/http-exception';
    
    const FAKE_DOMAIN = 'http://rate-limiter.internal/';
    
    function getRateLimitKey(c: any): string {
      // Prefer authenticated user sub; fall back to CF-connecting-ip
      const userSub = c.get('jwtPayload')?.sub;
      if (userSub) return userSub;
      return c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown';
    }
    
    function getCacheKey(endpoint: string, key: string, limit: number, interval: number): string {
      return `${FAKE_DOMAIN}${endpoint}/${key}/${limit}/${interval}`;
    }
    
    /**
     * @param interval - window size in seconds (e.g. 60)
     * @param limit    - max requests per window (e.g. 10)
     *
     * Usage:
     *   route.post('/send-email', auth(), rateLimit(120, 1), handler)  // 1 req per 2 min
     *   route.get('/api/search',  auth(), rateLimit(60, 30), handler)  // 30 req per min
     */
    export const rateLimit = (interval: number, limit: number): MiddlewareHandler => {
      return async (c, next) => {
        const key = getRateLimitKey(c);
        const endpoint = new URL(c.req.url).pathname;
    
        // Cache API short-circuit — if this key is blocked, skip the DO entirely
        const cache = await caches.open('rate-limiter');
        const cacheKey = getCacheKey(endpoint, key, limit, interval);
        const cached = await cache.match(cacheKey);
    
        let res: Response;
        if (cached) {
          res = cached;
        } else {
          // Route to the user's dedicated DO shard
          const id = c.env.RATE_LIMITER.idFromName(key);
          const stub = c.env.RATE_LIMITER.get(id);
    
          res = await stub.fetch(
            new Request(FAKE_DOMAIN, {
              method: 'POST',
              body: JSON.stringify({ scope: endpoint, key, limit, interval }),
            })
          );
        }
    
        const body = await res.clone().json<{
          blocked: boolean;
          remaining: number;
          expires: string;
        }>();
    
        const secondsExpires = dayjs(body.expires).unix() - dayjs().unix();
    
        // Set standard rate limit headers
        c.header('X-RateLimit-Limit', String(limit));
        c.header('X-RateLimit-Remaining', String(body.remaining));
        c.header('X-RateLimit-Reset', String(secondsExpires));
        c.header('X-RateLimit-Policy', `${limit};w=${interval};comment="Sliding window"`);
    
        if (body.blocked) {
          // Cache blocked responses — saves DO invocations for repeat offenders
          if (!cached) {
            c.executionCtx.waitUntil(cache.put(cacheKey, res));
          }
          throw new HTTPException(429, { message: 'Too many requests' });
        }
    
        await next();
      };
    };
    ```
    
    ## Wiring into Hono routes
    
    ```ts
    // src/worker/index.ts
    import { Hono } from 'hono';
    import { jwtMiddleware } from './middlewares/auth';
    import { rateLimit } from './middlewares/rate-limiter';
    
    const app = new Hono<{ Bindings: Env }>();
    
    // 1 request per 2 minutes for email sending (prevent spam)
    app.post('/api/send-verification-email',
      jwtMiddleware(),
      rateLimit(120, 1),
      emailController.sendVerification
    );
    
    // 30 requests per minute for search
    app.get('/api/search',
      jwtMiddleware(),
      rateLimit(60, 30),
      searchController.query
    );
    
    // 5 requests per minute for AI generation (cost protection)
    app.post('/api/generate',
      jwtMiddleware(),
      rateLimit(60, 5),
      aiController.generate
    );
    
    // Public endpoint — rate limit by IP only (no auth middleware)
    app.post('/api/contact',
      rateLimit(3600, 3), // 3 per hour
      contactController.submit
    );
    ```
    
    ## Bindings type declaration
    
    ```ts
    // src/bindings.d.ts
    export interface Env {
      RATE_LIMITER: DurableObjectNamespace;
      // ... other bindings
    }
    ```
    
    ## How the alarm-based cleanup works
    
    ```
    On every DO fetch:
      → setAlarm() called (no-op if alarm already scheduled)
      → alarm fires 6h later
      → iterates all storage keys
      → parses windowTimestamp from composite key
      → deletes any key where timestamp < currentWindow - 2
      → data older than 2 full intervals is purged
    
    Storage grows at: O(active_users × 2 keys per user)
    Two keys: current window + previous window (needed for sliding formula)
    Cleanup cap: ≤2 intervals worth of data per DO instance
    ```
    
    ## Composite key anatomy
    
    ```
    "scope|key|limit|interval|windowTimestamp"
     ↑      ↑    ↑     ↑        ↑
     path   sub  100   60       28645012  (unix_seconds / interval)
    
    Example:
    "/api/generate|user-abc123|5|60|28645012"
    "/api/generate|user-abc123|5|60|28645011"  ← previous window, used for sliding calc
    ```
    
    ## Gotchas
    
    - **One DO shard per user** — `idFromName(userSub)` means each user has isolated state. If you use `idFromName('global')`, you create a global bottleneck (hot DO)
    - **Cache API is per-PoP** — the `caches.open()` short-circuit only caches within a single CF edge location. A user hitting different PoPs will bypass the cache. This is acceptable for rate limiting (slightly lenient across PoPs is better than a global lock)
    - **`dayjs` import** — the original boilerplate uses `dayjs`. You can swap for `Date.now()` native math to drop the dependency: `const nowUnix = () => Math.floor(Date.now() / 1000)`
    - **Don't use `idFromString(uuid)`** — `idFromName(key)` is stable (same name → same DO always). `idFromString` requires a pre-generated DO ID and doesn't deduplicate on key
    - **Storage.put is synchronous in memory but async on disk** — the DO guarantees the put is committed before the response is returned when using `await`
    
    ## Cross-links
    
    - `[[cf-agents-do-pattern]]` — same DO patterns; agents use DO for state, rate limiter uses DO for counters
    - `[[cf-zero-trust-access]]` — even Access-protected admin routes benefit from rate limiting for service token abuse prevention
    - `[[hono-api]]` — middleware composition, `HTTPException` for clean 429 responses
    
  • cf-hyperdrive.md 8 KB
    ---
    name: "cf-hyperdrive"
    priority: 2
    pack: "architecture"
    triggers:
      - "hyperdrive"
      - "postgres"
      - "postgresql"
      - "mysql"
      - "external database"
      - "connection pooling"
      - "legacy database"
      - "hybrid database"
      - "migrate to cloudflare"
      - "multi-region read"
    paths:
      - "**/wrangler.{toml,jsonc}"
      - "**/db/**"
      - "**/database/**"
      - "**/*.sql"
    ---
    
    # CF Hyperdrive
    
    Accelerates external Postgres/MySQL from Workers by pooling connections at the CF edge and caching hot queries globally. Eliminates the cold-connection latency spike (typically 100-500ms) that makes Workers + external DBs feel slow.
    
    Source: `developers.cloudflare.com/hyperdrive`. See `[[cloudflare-lock-in-is-leverage]]`.
    
    ## When to use Hyperdrive vs D1
    
    | Factor | Use D1 | Use Hyperdrive + external DB |
    |---|---|---|
    | New greenfield project | ✓ | — |
    | Data already in Postgres/MySQL | — | ✓ |
    | Need Postgres extensions (PostGIS, pgvector, TimescaleDB) | — | ✓ |
    | Large dataset (>10 GB) | — | ✓ (D1 limit is 10 GB) |
    | Complex joins, stored procedures | — | ✓ |
    | Global read replicas (Neon, PlanetScale, CockroachDB) | — | ✓ |
    | Zero ops, fully managed | ✓ | — |
    | Cost: pay per query | D1 is cheaper | Hyperdrive adds ~$0.50/1M rows + DB cost |
    
    **Brian's default**: D1 for new projects. Hyperdrive for hybrid migrations and projects requiring Neon (branching, point-in-time recovery) or Postgres-specific features.
    
    ## Quick start
    
    ```bash
    # 1. Create Hyperdrive config (one per DB)
    npx wrangler hyperdrive create my-db \
      --connection-string="postgres://user:password@db.example.com:5432/mydb"
    
    # 2. Output includes the config ID — copy it
    # Output: Created Hyperdrive config my-db with ID: abc123...
    
    # 3. With caching disabled (for write-heavy or real-time data):
    npx wrangler hyperdrive create my-db-nocache \
      --connection-string="postgres://..." \
      --caching-disabled
    
    # 4. Custom cache TTL (default is 60s):
    npx wrangler hyperdrive create my-db-long \
      --connection-string="postgres://..." \
      --max-age=300
    ```
    
    ## wrangler.toml binding
    
    ```toml
    [[hyperdrive]]
    binding = "HYPERDRIVE"
    id = "abc123yourhyperdriveConfigId"
    
    # Local dev: point at local Postgres (bypasses Hyperdrive tunnel)
    localConnectionString = "postgres://user:password@localhost:5432/mydb_dev"
    ```
    
    Multiple Hyperdrive configs (read replica + primary):
    
    ```toml
    [[hyperdrive]]
    binding = "DB_PRIMARY"
    id = "abc123primaryConfigId"
    
    [[hyperdrive]]
    binding = "DB_REPLICA"
    id = "def456replicaConfigId"
    ```
    
    ## Worker code — postgres.js (recommended)
    
    ```ts
    import postgres from 'postgres';
    
    export interface Env {
      HYPERDRIVE: Hyperdrive;
    }
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        // Create a new client per request — Hyperdrive pools the actual TCP connection
        // so this is cheap: no new handshake, no TLS negotiation
        const sql = postgres(env.HYPERDRIVE.connectionString, {
          max: 5,           // max connections from this Worker instance
          fetch_types: false, // required for Workers compatibility
        });
    
        try {
          const users = await sql`
            SELECT id, email, created_at
            FROM users
            WHERE active = true
            ORDER BY created_at DESC
            LIMIT 50
          `;
          return Response.json(users);
        } finally {
          await sql.end(); // return connection to pool — do NOT skip
        }
      },
    };
    ```
    
    ## Worker code — pg (node-postgres)
    
    ```ts
    import { Client } from 'pg';
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
        await client.connect();
    
        try {
          const { rows } = await client.query(
            'SELECT id, name FROM products WHERE tenant_id = $1',
            ['tenant-abc']
          );
          return Response.json(rows);
        } finally {
          await client.end();
        }
      },
    };
    ```
    
    ## Prepared statement caching
    
    Hyperdrive caches prepared statements at the pooler layer — your Worker sends a parameterized query once, and Hyperdrive reuses the cached execution plan on subsequent calls. This is automatic and opt-in per-query.
    
    ```ts
    // Hyperdrive caches the query plan for this parameterized form
    // The first call parses + plans; subsequent calls skip planning overhead
    const result = await sql`
      SELECT * FROM orders
      WHERE tenant_id = ${tenantId}
        AND status = ${status}
      ORDER BY created_at DESC
      LIMIT ${limit}
    `;
    
    // Hyperdrive does NOT cache non-parameterized queries with interpolated values
    // BAD — no plan reuse, potential SQL injection:
    const bad = await sql.unsafe(`SELECT * FROM orders WHERE tenant_id = '${tenantId}'`);
    ```
    
    ## Query result caching
    
    Hyperdrive caches SELECT query results globally at the CF edge. Cache key = normalized SQL + parameters.
    
    ```ts
    // This result is cached for up to max-age seconds (default 60s)
    const cachedResult = await sql`SELECT * FROM products WHERE active = true`;
    
    // Cache is bypassed automatically for:
    // - Transactions (BEGIN/COMMIT)
    // - Mutations (INSERT/UPDATE/DELETE)
    // - Queries with session-level state (SET, pg_advisory_lock, etc.)
    
    // Force cache bypass for real-time data (use --caching-disabled config instead)
    // Or use a separate Hyperdrive config without caching for that binding
    ```
    
    ## Multi-region pattern with Neon
    
    ```toml
    # wrangler.toml
    [[hyperdrive]]
    binding = "NEON_PRIMARY"
    id = "primary-config-id"
    
    [[hyperdrive]]
    binding = "NEON_REPLICA"
    id = "replica-config-id"   # Neon read replica in same region as CF PoP
    ```
    
    ```ts
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const method = request.method;
    
        // Route reads to replica (lower latency, cached), writes to primary
        const connString = method === 'GET'
          ? env.NEON_REPLICA.connectionString
          : env.NEON_PRIMARY.connectionString;
    
        const sql = postgres(connString, { max: 3, fetch_types: false });
        // ... query
        await sql.end();
      },
    };
    ```
    
    ## Drizzle ORM integration
    
    ```ts
    import { drizzle } from 'drizzle-orm/postgres-js';
    import postgres from 'postgres';
    import * as schema from './schema';
    
    export function getDb(env: Env) {
      const client = postgres(env.HYPERDRIVE.connectionString, {
        max: 5,
        fetch_types: false,
      });
      return drizzle(client, { schema });
    }
    
    // Usage in handler:
    const db = getDb(env);
    const users = await db.select().from(schema.users).where(eq(schema.users.active, true));
    ```
    
    ## Supported databases
    
    | Database | Connection string prefix | Notes |
    |---|---|---|
    | PostgreSQL (self-hosted) | `postgres://` | v12+ |
    | Neon | `postgres://` | Serverless driver or standard; use standard for Hyperdrive |
    | Supabase | `postgres://` | Use pooler connection string (port 6543) |
    | CockroachDB | `postgres://` | Use `sslmode=require` |
    | TimescaleDB | `postgres://` | Postgres-compatible |
    | MySQL | `mysql://` | v8.0+ |
    | PlanetScale | `mysql://` | MySQL-compatible |
    
    ## Gotchas
    
    - **Create client per request, not per Worker** — Hyperdrive manages the underlying pool; creating a new `postgres()` client per request is intentional and cheap (no new TCP/TLS per call)
    - **`fetch_types: false` required** — postgres.js's type fetching queries the `pg_type` catalog on connection; this breaks Workers' restricted runtime. Always set this flag
    - **Transactions bypass caching** — any query inside `BEGIN/COMMIT` is never cached; route transaction-heavy workloads to the primary only
    - **`await sql.end()` in finally** — skipping this leaks the connection back to Hyperdrive's pool in an unknown state; always end in a `finally` block
    - **Local dev uses `localConnectionString`** — `wrangler dev` bypasses Hyperdrive entirely and connects directly to your local Postgres; CI should use a separate local DB
    - **Max 25 Hyperdrive configs** per account on free tier
    
    ## Cross-links
    
    - `[[cloudflare-lock-in-is-leverage]]` — when NOT to migrate off Postgres
    - `[[cf-agents-do-pattern]]` — agents use D1 (via sql`` tag) for agent-local state; Hyperdrive for shared relational data
    - `[[drizzle-orm-and-migrations]]` — Drizzle works identically with Hyperdrive as with D1
    
  • cf-rag-vectorize-pattern.md 5.6 KB
    ---
    name: "cf-rag-vectorize-pattern"
    priority: 2
    pack: "architecture"
    triggers:
      - "rag"
      - "vectorize"
      - "embedding"
      - "semantic search"
      - "knowledge base"
    paths:
      - "**/vectorize/**"
      - "**/embeddings/**"
      - "**/rag/**"
    ---
    
    # CF RAG — Workers AI + Vectorize + D1 + Workflows
    
    Reference RAG pipeline on Cloudflare-native primitives. No LangChain, no Pinecone, no Supabase pgvector. Just Workers AI for embeddings, Vectorize for storage/query, D1 for raw text + metadata, Workflows for durable ingestion.
    
    Source: `kristianfreeman/cloudflare-retrieval-augmented-generation-example` (★135). See `[[cloudflare-lock-in-is-leverage]]`.
    
    ## When to use
    
    - Need semantic search over a corpus (docs, blog posts, support tickets, transcripts)
    - Need grounded LLM responses citing your corpus
    - Already on CF — don't drag in Pinecone/Supabase if you don't have to
    
    ## Pattern A: Ingestion as a Workflow — atomic steps
    
    ```ts
    import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';
    
    export class RAGWorkflow extends WorkflowEntrypoint<Env, { text: string }> {
      async run(event: WorkflowEvent<{ text: string }>, step: WorkflowStep) {
        const { text } = event.payload;
    
        // Step 1 — persist raw text in D1 (idempotent via INSERT RETURNING)
        const record = await step.do('create database record', async () => {
          const { results } = await this.env.DATABASE.prepare(
            'INSERT INTO notes (text) VALUES (?) RETURNING *'
          ).bind(text).run<Note>();
          return results[0];
        });
    
        // Step 2 — embed (768-dim for bge-base-en-v1.5)
        const embedding = await step.do('generate embedding', async () => {
          const e = await this.env.AI.run('@cf/baai/bge-base-en-v1.5', { text });
          if (!e.data[0]) throw new Error('Failed to generate vector embedding');
          return e.data[0]; // number[]
        });
    
        // Step 3 — upsert to Vectorize keyed by D1 row id
        await step.do('insert vector', async () =>
          this.env.VECTOR_INDEX.upsert([{ id: record.id.toString(), values: embedding }])
        );
      }
    }
    ```
    
    **Why Workflows here**: if embedding fails (Workers AI hiccup), step 1 is NOT re-run — D1 row already exists. Each `step.do()` is independently retried with exponential backoff.
    
    ## Pattern B: Query — Vectorize → D1 join
    
    ```ts
    app.get('/search', async (c) => {
      const question = c.req.query('q')!;
    
      const embeddings = await c.env.AI.run('@cf/baai/bge-base-en-v1.5', { text: question });
      const vectorQuery = await c.env.VECTOR_INDEX.query(embeddings.data[0], { topK: 3 });
    
      const ids = vectorQuery.matches.map(m => m.id);
      const { results } = await c.env.DATABASE
        .prepare(`SELECT * FROM notes WHERE id IN (${ids.map(() => '?').join(',')})`)
        .bind(...ids)
        .all<Note>();
    
      return c.json({ notes: results, scores: vectorQuery.matches.map(m => m.score) });
    });
    ```
    
    Vectorize stores ONLY embedding vectors. D1 stores the actual text. Join by ID at query time.
    
    ## Pattern C: Grounded answer — RAG prompt with Anthropic via AI Gateway
    
    ```ts
    const systemPrompt = 'Use the provided context, if relevant. If you cannot answer from context, say so.';
    const contextMessage = notes.length
      ? `Context:\n${notes.map((n, i) => `[${i+1}] ${n.text}`).join('\n')}`
      : '';
    
    // Anthropic primary via AI Gateway (cached, observable)
    const anthropic = new Anthropic({
      apiKey: c.env.ANTHROPIC_API_KEY,
      baseURL: `https://gateway.ai.cloudflare.com/v1/${c.env.CF_ACCOUNT_ID}/my-gateway/anthropic`,
    });
    
    await anthropic.messages.create({
      model: 'claude-sonnet-4-6',
      system: [systemPrompt, contextMessage].filter(Boolean).join('\n'),
      messages: [{ role: 'user', content: question }],
    });
    
    // Workers AI fallback (free, on-net):
    // await c.env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', { messages: [...] });
    ```
    
    Per `[[opus-quota-fallback]]` — fall back to Workers AI Llama if Anthropic quota exhausted.
    
    ## Pattern D: Paired delete — never orphan vectors
    
    ```ts
    app.delete('/notes/:id', async (c) => {
      const id = c.req.param('id');
      await c.env.DATABASE.prepare('DELETE FROM notes WHERE id = ?').bind(id).run();
      await c.env.VECTOR_INDEX.deleteByIds([id]); // orphan vectors silently pollute future queries
      return c.json({ deleted: id });
    });
    ```
    
    ## wrangler.jsonc — full RAG bindings
    
    ```jsonc
    {
      "ai": { "binding": "AI" },
      "vectorize": [{ "binding": "VECTOR_INDEX", "index_name": "myproject-semantic" }],
      "d1_databases": [{ "binding": "DATABASE", "database_name": "myproject", "database_id": "..." }],
      "workflows": [{ "name": "rag-workflow", "binding": "RAG_WORKFLOW", "class_name": "RAGWorkflow" }]
    }
    ```
    
    ## Vectorize index — create with correct dimensions
    
    ```sh
    # bge-base-en-v1.5 is 768 dimensions, cosine is appropriate
    wrangler vectorize create myproject-semantic --dimensions=768 --metric=cosine
    ```
    
    Mismatched dimensions = silent insertion failure. Always match the embedding model exactly.
    
    ## Anti-patterns
    
    - ❌ `@langchain/textsplitters` — adds bundle weight; for simple chunking, inline a 20-line splitter
    - ❌ Direct Anthropic SDK without AI Gateway — lose caching + observability + cost reduction (30-80% per `[[model-routing]]`)
    - ❌ Index name encoding dimensions (`tutorial-index-768`) — use semantic names like `myproject-semantic`
    - ❌ Storing text in Vectorize metadata — use D1 for text, Vectorize for vectors only
    
    ## Cross-link
    
    - `[[cloudflare-lock-in-is-leverage]]` — Vectorize > Pinecone
    - `[[model-routing]]` — AI Gateway routing
    - `[[opus-quota-fallback]]` — Workers AI Llama fallback
    - `[[zod-everywhere]]` — Zod schemas at every boundary
    - `cf-workflows-pattern` (this dir) — pattern reused
    - `cf-agents-do-pattern` (this dir) — agents can call RAG queries via tools
    
  • cf-saas-template-stack.md 6.6 KB
    ---
    name: "cf-saas-template-stack"
    priority: 2
    pack: "architecture"
    triggers:
      - "saas"
      - "starter"
      - "template"
      - "better auth"
      - "drizzle"
      - "tanstack start"
    paths:
      - "**/auth.ts"
      - "**/drizzle.config.ts"
      - "**/alchemy.run.ts"
    ---
    
    # CF SaaS Template Stack — Battle-tested Building Blocks
    
    Distilled from 5 production CF SaaS starters (sagyzdop/mvp-app, darkhorse-03/Zynth, zett-8/hono-react-router, yusukebe/honox-starter, AmanVarshney01/create-better-t-stack). These are the exact patterns to clone-and-adapt into `megabytespace/saas-starter` for the `/saas` slash command.
    
    ## Better Auth on D1 — TanStack Start wiring
    
    `tanstackStartCookies()` is non-obvious and required for SSR cookie handling. Without it, sessions don't survive page reloads.
    
    ```ts
    // src/lib/auth/auth.ts
    import { betterAuth } from 'better-auth';
    import { drizzleAdapter } from 'better-auth/adapters/drizzle';
    import { tanstackStartCookies } from 'better-auth/cookies'; // CRITICAL for TanStack Start SSR
    import { drizzle } from 'drizzle-orm/d1';
    import * as schema from '@/db/schema';
    
    export const createAuth = (env: Env) => betterAuth({
      database: drizzleAdapter(drizzle(env.DB), {
        provider: 'sqlite',
        schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
      }),
      plugins: [tanstackStartCookies()],
      socialProviders: {
        google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
        github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
      },
      emailAndPassword: { enabled: true },
    });
    ```
    
    Source: `sagyzdop/mvp-app:src/lib/auth/auth.ts`. Lands in: `megabytespace/saas-starter/src/lib/auth.ts`.
    
    ## Drizzle config — D1-HTTP for remote migrations
    
    ```ts
    // drizzle.config.ts — `driver: 'd1-http'` enables remote migration without local wrangler dev
    import { defineConfig } from 'drizzle-kit';
    
    export default defineConfig({
      schema: './src/db/schema.ts',
      out: './drizzle',
      dialect: 'sqlite',
      driver: 'd1-http',
      dbCredentials: {
        accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
        databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID!,
        token: process.env.CLOUDFLARE_API_TOKEN!,
      },
    });
    ```
    
    Source: `sagyzdop/mvp-app`. Required for CI-driven migration workflows.
    
    ## Hono RPC — fully typed client via dummy-URL trick
    
    ```ts
    // src/hc.ts — workspace ref carries types, dummy URL never used
    import { hc } from 'hono/client';
    import type { AppType } from './worker'; // your Hono app type
    
    const client = hc<AppType>(''); // dummy URL — types only
    export type Client = typeof client;
    
    export const hcWithType = (...args: Parameters<typeof hc>): Client =>
      hc<AppType>(...args);
    ```
    
    Then in React Query:
    
    ```ts
    const c = hcWithType(window.location.origin);
    const { data } = useQuery({
      queryKey: ['orders'],
      queryFn: async () => (await c.api.orders.$get()).json(),
    });
    ```
    
    Zero codegen. Types flow from Hono routes → client → React Query. Source: `darkhorse-03/Zynth:src/hc.ts`.
    
    ## drizzle-zod — Zod schemas from Drizzle tables
    
    ```ts
    import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
    import { z } from 'zod';
    import { orders } from './schema';
    
    // Form schema: omit auto-generated fields with `{ id: z.undefined() }` trick
    export const insertOrderSchema = createInsertSchema(orders, {
      id: z.undefined(), // auto-incremented in D1
      createdAt: z.undefined(), // defaultNow()
    });
    
    export const selectOrderSchema = createSelectSchema(orders);
    
    // Use in Hono route + react-hook-form — same schema, zero duplication
    ```
    
    Source: `yusukebe/cloudflare-d1-drizzle-honox-starter`. Eliminates Zod boilerplate for every CRUD form.
    
    ## D1 per-request in Hono middleware
    
    ```ts
    // Cleanest pattern from 5 repos:
    import { createMiddleware } from 'hono/factory';
    import { drizzle } from 'drizzle-orm/d1';
    
    export const dbMiddleware = createMiddleware<{ Bindings: Env; Variables: { db: ReturnType<typeof drizzle> } }>(
      async (c, next) => {
        c.set('db', drizzle(c.env.DB));
        await next();
      }
    );
    
    app.use('*', dbMiddleware);
    
    // In routes:
    app.get('/orders', async (c) => {
      const db = c.get('db');
      const orders = await db.select().from(ordersTable);
      return c.json(orders);
    });
    ```
    
    Source: `yusukebe/cloudflare-d1-drizzle-honox-starter:app/routes/_middleware.ts`.
    
    ## React Router v7 framework mode on Workers
    
    ```ts
    // worker.ts — 3-line bridge from Workers to React Router 7 framework build
    import { createRequestHandler } from 'react-router';
    import { Hono } from 'hono';
    
    const app = new Hono<{ Bindings: Env }>();
    
    app.use('*', async (c) => {
      const handler = createRequestHandler(
        // @ts-expect-error virtual module
        () => import('virtual:react-router/server-build'),
        'production'
      );
      return await handler(c.req.raw, { cloudflare: { env: c.env, ctx: c.executionCtx } });
    });
    
    export default app;
    ```
    
    Source: `zett-8/hono-react-router:worker.ts`. SSR React Router 7 framework mode on Workers without Next.js.
    
    ## Alchemy IaC — replaces wrangler.toml at scale
    
    ```ts
    // alchemy.run.ts — programmatic CF resource provisioning, idempotent via `adopt: true`
    import alchemy from 'alchemy';
    import { Worker, D1Database, R2Bucket, Vectorize } from 'alchemy/cloudflare';
    
    const app = alchemy('my-saas');
    
    const db = await D1Database('db', { adopt: true });
    const bucket = await R2Bucket('uploads', { adopt: true });
    const index = await Vectorize('semantic', { adopt: true, dimensions: 768, metric: 'cosine' });
    
    await Worker('api', {
      adopt: true,
      entrypoint: './src/worker.ts',
      bindings: { DB: db, BUCKET: bucket, INDEX: index },
    });
    
    await app.finalize();
    ```
    
    Run `bun run deploy` once → all resources provisioned + Worker deployed. `adopt: true` makes it idempotent — safe to re-run.
    
    Source: `darkhorse-03/Zynth:alchemy.run.ts`. Alternative to wrangler.jsonc when multi-resource provisioning gets complex.
    
    ## Dual-mode CLI binary — same bin dispatches CLI or MCP stdio
    
    ```ts
    // apps/cli/src/cli.ts
    if (process.argv[2] === 'mcp') {
      await startMcpServer(); // stdio MCP server
    } else {
      await runCli(); // interactive scaffolder
    }
    ```
    
    Source: `AmanVarshney01/create-better-t-stack:apps/cli/src/cli.ts`. Pattern for shipping a tool that's both a CLI and an MCP server from one binary.
    
    ## See also
    
    - `[[cloudflare-lock-in-is-leverage]]` — these are all CF primitives
    - `[[zod-everywhere]]` — `drizzle-zod` is the seam
    - `cf-agents-do-pattern` (this dir) — stateful agents on top
    - `cf-rag-vectorize-pattern` (this dir) — RAG on top
    - `cf-workflows-pattern` (this dir) — durable execution on top
    - `[[code-style]]` — TypeScript + Hono + Drizzle conventions
    - `commands/saas.md` — the `/saas` slash command that ties this all together
    
  • cf-websocket-do-pattern.md 11.1 KB
    ---
    skill: cf-websocket-do-pattern
    version: 1.0.0
    tags: [cloudflare, durable-objects, websocket, realtime, hibernation]
    cross-links: [cf-agents-do-pattern, hono-api]
    ---
    
    # CF WebSocket + Durable Objects Pattern
    
    ## Hibernation API
    
    - `acceptWebSocket(server)` inside `fetch()` — NOT `ws.accept()` (legacy keeps DO awake $)
    - DO handlers on the class: `webSocketMessage(ws, msg)`, `webSocketClose(ws, code, reason, wasClean)`, `webSocketError(ws, error)`
    - DO sleeps between messages, wakes on next — zero idle billing
    - `ctx.getWebSockets()` to enumerate all active sockets after cold wake
    - Tag sockets: `acceptWebSocket(server, ['room:xyz', 'user:abc'])` — query with `ctx.getWebSockets('room:xyz')`
    - Tags are durable across hibernation — stored by the runtime, not your code
    
    ```ts
    import { DurableObject } from 'cloudflare:workers';
    
    interface ConnMeta {
      userId: string;
      roomId: string;
      joinedAt: number;
      lastSeen: number;
      windowStart: number;
      msgCount: number;
    }
    
    export class RoomDO extends DurableObject {
      async fetch(request: Request): Promise<Response> {
        if (request.headers.get('Upgrade') !== 'websocket') {
          return new Response('Expected WebSocket', { status: 426 });
        }
        const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
        const userId = new URL(request.url).searchParams.get('userId') ?? 'anon';
        const roomId = new URL(request.url).searchParams.get('roomId') ?? 'default';
        this.ctx.acceptWebSocket(server, [`room:${roomId}`, `user:${userId}`]);
        server.serializeAttachment({ userId, roomId, joinedAt: Date.now(), lastSeen: Date.now(), windowStart: Date.now(), msgCount: 0 });
        this.broadcast({ type: 'presence:join', userId, ts: Date.now() }, server);
        return new Response(null, { status: 101, webSocket: client });
      }
    
      webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
        const meta = ws.deserializeAttachment() as ConnMeta;
        const now = Date.now();
        if (now - meta.windowStart > 10_000) { meta.windowStart = now; meta.msgCount = 0; }
        meta.lastSeen = now;
        if (++meta.msgCount > 50) {
          ws.send(JSON.stringify({ type: 'error', code: 'RATE_LIMITED' }));
          ws.close(1008, 'rate limited');
          return;
        }
        if (meta.msgCount > 40) ws.send(JSON.stringify({ type: 'warn', msg: 'approaching rate limit' }));
        ws.serializeAttachment(meta);
        let data: Record<string, unknown>;
        try { data = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message)); }
        catch { ws.send(JSON.stringify({ type: 'error', code: 'bad_json' })); return; }
        if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', ts: Date.now() })); return; }
        this.broadcast({ type: 'message', from: meta.userId, payload: data }, ws);
      }
    
      webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): void {
        const meta = ws.deserializeAttachment() as ConnMeta;
        this.broadcast({ type: 'presence:leave', userId: meta.userId, ts: Date.now() });
      }
    
      webSocketError(ws: WebSocket, error: unknown): void {
        console.error('ws error', error); // webSocketClose fires after this
      }
    
      broadcast(message: unknown, exclude?: WebSocket): void {
        const payload = JSON.stringify(message);
        for (const ws of this.ctx.getWebSockets()) {
          if (ws !== exclude) { try { ws.send(payload); } catch { /* already closed */ } }
        }
      }
    }
    ```
    
    ## Connection Metadata (Attachment API)
    
    - Never trust in-memory Map across hibernation — use `ws.serializeAttachment(meta)` / `ws.deserializeAttachment()`
    - Rebuild session info from attachment on each handler invocation
    - Attachment is JSON-serializable; store `{ userId, roomId, joinedAt, windowStart, msgCount }`
    - `ctx.getWebSockets()` returns live sockets even after DO hibernated and woke
    - Attachment size limit ~1 KB per socket — store IDs and timestamps, not payloads
    - For large per-user state: write to `this.ctx.storage` keyed by userId on each update
    
    ```ts
    // Hydrate presence from all live sockets after cold wake
    async getPresence(): Promise<ConnMeta[]> {
      return this.ctx.getWebSockets().map((ws) => ws.deserializeAttachment() as ConnMeta);
    }
    ```
    
    ## Broadcast Pattern
    
    Real TypeScript broadcast methods:
    
    ```ts
    broadcast(message: unknown, exclude?: WebSocket) {
      const payload = JSON.stringify(message);
      for (const ws of this.ctx.getWebSockets()) {
        if (ws !== exclude) {
          try { ws.send(payload); } catch { /* already closed */ }
        }
      }
    }
    
    broadcastToRoom(roomId: string, message: unknown, exclude?: WebSocket) {
      const payload = JSON.stringify(message);
      for (const ws of this.ctx.getWebSockets(`room:${roomId}`)) {
        if (ws !== exclude) {
          try { ws.send(payload); } catch {}
        }
      }
    }
    ```
    
    - Tag-scoped broadcast avoids iterating all sockets in multi-room DOs
    - Always wrap `ws.send()` in try/catch — sockets can close between `getWebSockets()` and send
    
    ## Presence Tracking
    
    - JOIN: on `fetch()` → broadcast `{ type: 'presence:join', userId, ts }` to room tag after accepting
    - LEAVE: in `webSocketClose` → broadcast `{ type: 'presence:leave', userId, ts }`
    - Persist presence to DO Storage: `await this.ctx.storage.put('presence', serialized)`; restore on wake
    - Heartbeat: client sends `{ type: 'ping' }` every 30s; DO responds `{ type: 'pong', ts: Date.now() }`
    - Stale detection: Alarm every 60s, iterate `ctx.getWebSockets()`, check `deserializeAttachment().lastSeen`, force-close if > 90s
    - New joiners hydrate from storage snapshot, not by querying live sockets
    
    ```ts
    async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
      // ... after handling ping/pong, schedule stale sweep
      if (!await this.ctx.storage.get('alarmSet')) {
        await this.ctx.storage.setAlarm(Date.now() + 60_000);
        await this.ctx.storage.put('alarmSet', true);
      }
    }
    
    async alarm() {
      const now = Date.now();
      for (const ws of this.ctx.getWebSockets()) {
        const meta = ws.deserializeAttachment() as ConnMeta;
        if (now - meta.lastSeen > 90_000) ws.close(1001, 'idle timeout');
      }
      await this.ctx.storage.delete('alarmSet');
      if (this.ctx.getWebSockets().length > 0) {
        await this.ctx.storage.setAlarm(Date.now() + 60_000);
        await this.ctx.storage.put('alarmSet', true);
      }
    }
    ```
    
    ## Message Rate Limiting Per Connection
    
    Real TypeScript inside `webSocketMessage`:
    
    ```ts
    webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
      const meta = ws.deserializeAttachment() as ConnMeta;
      const now = Date.now();
      if (now - meta.windowStart > 10_000) {
        meta.windowStart = now;
        meta.msgCount = 0;
      }
      meta.lastSeen = now;
      if (++meta.msgCount > 50) {
        ws.send(JSON.stringify({ type: 'error', code: 'RATE_LIMITED' }));
        ws.close(1008, 'rate limited');
        return;
      }
      if (meta.msgCount > 40) {
        ws.send(JSON.stringify({ type: 'warn', msg: 'approaching rate limit' }));
      }
      ws.serializeAttachment(meta);
      // handle message...
    }
    ```
    
    - 50 msgs / 10s window per connection; soft warn at 40; hard close + 1008 at 51
    - Per-connection window, not global DO-level
    - Tune per use-case: cursor sync 120/s, chat 10/s, commands 5/s
    
    ## Client-Side Reconnection Backoff
    
    Real TypeScript:
    
    ```ts
    let attempt = 0;
    function connect(url: string) {
      const ws = new WebSocket(url);
      ws.onopen = () => { attempt = 0; };
      ws.onclose = (e) => {
        if (e.code === 1008 || e.code === 4001) return; // no retry
        const delay = Math.min(30_000, 500 * 2 ** attempt + Math.random() * 500);
        attempt++;
        setTimeout(() => connect(url), delay);
      };
      ws.onmessage = (e) => {
        const msg = JSON.parse(e.data);
        if (msg.type === 'pong') return;
        handleMessage(msg);
      };
      return ws;
    }
    ```
    
    - Jittered exponential: 500ms → 1s → 2s → ... → 30s cap
    - Hard stop on 1008 (rate limited), 4001 (auth failed), 4003 (banned)
    - Client sends ping every 30s to keep connection alive through proxies
    - Show "Reconnecting…" in UI after attempt 2, "Check connection" after attempt 5
    
    ## Hono Entry Point Wiring
    
    ```ts
    // worker entry
    app.get('/ws/:roomId', async (c) => {
      const upgrade = c.req.header('Upgrade');
      if (upgrade !== 'websocket') return c.text('Expected websocket', 426);
      const roomId = c.req.param('roomId');
      const id = c.env.ROOM.idFromName(roomId);
      const stub = c.env.ROOM.get(id);
      return stub.fetch(c.req.raw);
    });
    
    // inside DO fetch()
    async fetch(request: Request): Promise<Response> {
      const url = new URL(request.url);
      if (url.pathname === '/connect') {
        const pair = new WebSocketPair();
        const [client, server] = Object.values(pair);
        const userId = await this.authenticate(request);
        if (!userId) return new Response('Unauthorized', { status: 401 });
        this.ctx.acceptWebSocket(server, [`room:${this.roomId}`, `user:${userId}`]);
        server.serializeAttachment({ userId, roomId: this.roomId, joinedAt: Date.now(), windowStart: Date.now(), msgCount: 0, lastSeen: Date.now() });
        this.broadcast({ type: 'presence:join', userId }, server);
        return new Response(null, { status: 101, webSocket: client });
      }
      return new Response('Not found', { status: 404 });
    }
    ```
    
    - `idFromName(roomId)` = deterministic DO per room — never `newUniqueId()` for shared resources
    - Auth in the Worker before forwarding — DO trusts the stub, not the raw client
    - Pass userId in URL params so DO can extract without re-parsing auth headers
    
    ## wrangler.toml Config
    
    ```toml
    [[durable_objects.bindings]]
    name = "ROOM"
    class_name = "RoomDO"
    
    [[migrations]]
    tag = "v1"
    new_sqlite_classes = ["RoomDO"]
    ```
    
    - `new_sqlite_classes` required for hibernation + built-in SQLite storage
    - Set `max_hibernatable_event_time_ms = 10000` in DO options if heavy per-message compute
    - Export the DO class from the worker entry file: `export { RoomDO }`
    - `[[migrations]]` tag must be unique per entry — increment tag on schema changes
    
    ## Use Cases
    
    - **Chat**: DO name = room ID; circular buffer last 100 msgs in Storage (`storage.put('msgs', ring)`)
    - **Live cursors**: client throttles to 30fps before sending; DO broadcasts position diffs (not absolute coords)
    - **Multiplayer state**: last-write-wins for simple state; for text use Yjs updates over WS, DO as relay only
    - **Real-time collab**: operational transforms in a separate Worker; DO handles transport only
    - **Live presence feeds**: `getWebSockets().map(ws => ws.deserializeAttachment())` → users online list
    
    ## Production Checklist
    
    - Never store `WebSocket` refs in class properties — they don't survive hibernation
    - Always wrap `ws.send()` in try/catch — socket may close between `getWebSockets()` and send
    - Use `idFromName(roomId)` not `idFromString()` — deterministic, no storage overhead
    - Cap rooms: enforce max 1000 connections per DO (check `ctx.getWebSockets().length` on join)
    - Alarm for cleanup: purge stale presence + emit analytics event per room
    - Test hibernation locally: `wrangler dev` does NOT simulate hibernation — test on deployed preview
    - `webSocketError` must be implemented — missing it causes unhandled rejection
    - Auth validated in Worker BEFORE forwarding to DO — never in DO unless also in Worker
    - `[[migrations]]` entry present — omitting silently skips storage provisioning on deploy
    
    ---
    
    See `[[cf-agents-do-pattern]]` for AI agent + DO integration. See `[[hono-api]]` for Hono routing patterns.
    
  • cf-workflows-pattern.md 5.3 KB
    ---
    name: "cf-workflows-pattern"
    priority: 2
    pack: "architecture"
    triggers:
      - "workflow"
      - "durable execution"
      - "background job"
      - "step.do"
      - "long running"
    paths:
      - "**/workflows/**"
    ---
    
    # Cloudflare Workflows — Durable Multi-Step Execution
    
    Native CF primitive for long-running, retry-safe, hibernation-friendly multi-step processes. Replaces Inngest, Trigger.dev, Temporal for most use cases. See `[[cloudflare-lock-in-is-leverage]]`.
    
    Source: `cloudflare/workflows-starter` (★47), `cloudflare/dynamic-workflows` (per-tenant variant — separate submodule).
    
    ## When to use
    
    - Multi-step processes where intermediate state must survive crashes/restarts
    - Human-in-the-loop approval gates (`step.waitForEvent()`)
    - Webhook-driven processes that may take minutes/hours
    - Async ingestion (RAG, image processing, video transcoding)
    - Anywhere you'd reach for Inngest — use Workflows first
    
    ## Pattern A: `step.do()` with retry config
    
    ```ts
    import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';
    
    export class OrderWorkflow extends WorkflowEntrypoint<Env, { orderId: string }> {
      async run(event: WorkflowEvent<{ orderId: string }>, step: WorkflowStep) {
        const order = await step.do('fetch order', async () => {
          return await this.env.DB.prepare('SELECT * FROM orders WHERE id = ?')
            .bind(event.payload.orderId).first<Order>();
        });
    
        // Idempotent write with retries
        const payment = await step.do('charge payment', {
          retries: { limit: 5, delay: '5 second', backoff: 'exponential' },
          timeout: '15 minutes',
        }, async () => {
          // Use idempotency key to prevent double-charge on retry
          return await chargeSquare(order.total, { idempotencyKey: order.id });
        });
    
        await step.do('send confirmation', async () => {
          await this.env.RESEND.emails.send({
            from: 'orders@example.com',
            to: order.email,
            subject: 'Order confirmed',
            html: renderTemplate({ order, payment }),
          });
        });
      }
    }
    ```
    
    **Critical**: steps may re-execute on retry. Use idempotency keys (Square, Stripe both support) or check-then-act for non-idempotent operations.
    
    ## Pattern B: `step.waitForEvent()` — human approval gate
    
    ```ts
    const approval = await step.waitForEvent('request-approval', {
      type: 'approval',
      timeout: '24 hours',
    });
    
    if (approval.payload.decision === 'approved') {
      await step.do('execute', async () => { /* ... */ });
    } else {
      await step.do('notify rejection', async () => { /* ... */ });
    }
    ```
    
    Instance **hibernates** (zero cost) while waiting. Resume via REST:
    
    ```sh
    curl -X POST https://api.cloudflare.com/client/v4/accounts/$ACCT/workflows/$NAME/instances/$ID/events/request-approval \
      -H "Authorization: Bearer $TOKEN" \
      -d '{"decision":"approved","approver":"brian@megabyte.space"}'
    ```
    
    ## Pattern C: `step.sleep()` — schedule future work without burning compute
    
    ```ts
    await step.do('send welcome email', async () => { /* ... */ });
    await step.sleep('1 day');
    await step.do('send onboarding tips email', async () => { /* ... */ });
    await step.sleep('7 days');
    await step.do('send trial-ending email', async () => { /* ... */ });
    ```
    
    Replaces cron + DB-tracked queue. Workflow hibernates between sleeps.
    
    ## wrangler.jsonc — workflow binding
    
    ```jsonc
    {
      "workflows": [{
        "name": "order-workflow",
        "binding": "ORDER_WORKFLOW",
        "class_name": "OrderWorkflow"
      }],
      "observability": { "enabled": true, "head_sampling_rate": 1 }
    }
    ```
    
    `class_name` must match the exported class name EXACTLY. `observability` block feeds Workers Tracing OTLP — needed for debugging long-running workflow instances.
    
    ## Triggering a workflow
    
    ```ts
    app.post('/orders', async (c) => {
      const body = await c.req.json<{ orderId: string }>();
      const instance = await c.env.ORDER_WORKFLOW.create({
        id: body.orderId, // dedup key — second create with same id throws
        params: body,
      });
      return c.json({ workflowId: instance.id, status: await instance.status() });
    });
    ```
    
    Pass the order ID as the workflow instance ID for natural dedup. Second `create` with same ID throws — meaning duplicate POST is idempotent at the workflow layer.
    
    ## Compatibility date gotcha
    
    ```jsonc
    "compatibility_date": "2024-10-22" // or later
    ```
    
    Workflows silently break on earlier compat dates. Always set ≥ 2024-10-22.
    
    ## Anti-patterns
    
    - ❌ Putting non-idempotent writes bare in `step.do()` — wrap with idempotency key OR use upsert
    - ❌ Omitting `observability` config — debugging hibernating workflows is impossible without OTLP
    - ❌ Using `setTimeout` or `Date.now()` inside steps — non-deterministic across retries; use `step.sleep()` and pass timestamps via params
    - ❌ Reaching for Inngest when CF Workflows fits — see `[[cloudflare-lock-in-is-leverage]]`
    
    ## See also for per-tenant variant
    
    When each tenant runs different workflow code (multi-tenant SaaS), see `cf-dynamic-workflows-pattern.md` (separate submodule using `cloudflare/dynamic-workflows`).
    
    ## Cross-link
    
    - `[[cloudflare-lock-in-is-leverage]]` — Workflows > Inngest for most cases
    - `cf-rag-vectorize-pattern` (this dir) — RAG ingestion uses Workflows
    - `cf-agents-do-pattern` (this dir) — agents can trigger workflows via `workflow.create()`
    - `[[verification-loop]]` — workflows count as deployed surfaces; assert via REST
    - `[[error-recovery]]` — retry + idempotency patterns
    
  • cf-zero-trust-access.md 8.3 KB
    ---
    name: "cf-zero-trust-access"
    priority: 2
    pack: "architecture"
    triggers:
      - "zero trust"
      - "CF Access"
      - "cloudflare access"
      - "access policy"
      - "admin dashboard"
      - "internal tool"
      - "beta feature"
      - "JWT verification"
      - "protect route"
      - "auth without boilerplate"
    paths:
      - "**/wrangler.{toml,jsonc}"
      - "**/middleware*"
      - "**/admin/**"
      - "**/internal/**"
    ---
    
    # CF Zero Trust Access
    
    Protect Worker routes with identity-aware access policies — no auth boilerplate, no session management, no login UI to build. CF Access sits in front of your Worker, validates identity via your IdP (Google, GitHub, Okta, Azure AD, etc.), issues a signed JWT, and forwards it on every request.
    
    Source: `developers.cloudflare.com/cloudflare-one`, `@hono/cloudflare-access`. See `[[cloudflare-lock-in-is-leverage]]`.
    
    ## When to use CF Access vs Clerk
    
    | Scenario | Use |
    |---|---|
    | Admin dashboard, internal tooling, `/admin/*` routes | **CF Access** — zero code, IdP-backed, audit logs included |
    | End-user SaaS login, multi-tenant user accounts | **Clerk** — full UI, magic links, MFA, org management |
    | Beta feature gate for specific emails/groups | **CF Access** — policy by email list or IdP group |
    | Machine-to-machine (M2M) service tokens | **CF Access** — service tokens with no human login |
    | Developer tools, staging environments | **CF Access** — one policy, no env vars in app code |
    
    ## Setup (CF Dashboard)
    
    1. Zero Trust → Access → Applications → Add an application → Self-hosted
    2. Set **Application domain**: `admin.yourdomain.com` or `yourdomain.com/admin/*`
    3. Create policy: Allow → Include → Emails / Email domain / IdP group / Everyone
    4. Copy the **Audience tag** (AUD) — needed for JWT verification
    5. Note your **Team domain**: `your-team.cloudflareaccess.com`
    
    CF now intercepts every request to that path, redirects unauthenticated users to your IdP login, and forwards authenticated requests with a signed `Cf-Access-Jwt-Assertion` header.
    
    ## JWT structure
    
    CF Access injects this header on every authenticated request:
    
    ```
    Cf-Access-Jwt-Assertion: eyJ...
    ```
    
    Claims in the payload:
    
    ```json
    {
      "iss": "https://your-team.cloudflareaccess.com",
      "aud": ["your-application-audience-tag"],
      "email": "brian@megabyte.space",
      "sub": "user-uuid",
      "iat": 1718700000,
      "exp": 1718703600
    }
    ```
    
    Public keys for verification: `https://your-team.cloudflareaccess.com/cdn-cgi/access/certs`
    
    Always match the `kid` in the JWT header to `public_certs` (not `public_cert`) — the single-cert endpoint may serve a cached expired key during rotation.
    
    ## Pattern 1 — `@hono/cloudflare-access` middleware (recommended)
    
    Zero manual JWT verification. The middleware fetches CF's public keys, verifies the token, and populates `c.get('accessPayload')`.
    
    ```bash
    npm i @hono/cloudflare-access
    ```
    
    ```ts
    // src/worker/index.ts
    import { Hono } from 'hono';
    import { cloudflareAccess, type CloudflareAccessVariables } from '@hono/cloudflare-access';
    
    type Variables = CloudflareAccessVariables; // adds accessPayload to c.get()
    
    const app = new Hono<{ Bindings: Env; Variables: Variables }>();
    
    // Protect all /admin/* routes
    app.use(
      '/admin/*',
      cloudflareAccess(
        'your-team',              // team name (subdomain of cloudflareaccess.com)
        'your-aud-tag-here'       // audience tag from CF Access dashboard
      )
    );
    
    app.get('/admin/dashboard', (c) => {
      const payload = c.get('accessPayload');
      return c.json({
        message: `Hello ${payload.email}`,
        sub: payload.sub,
      });
    });
    
    app.get('/admin/feature-flags', (c) => {
      // payload.email is the verified CF Access identity — use for audit logs
      const { email } = c.get('accessPayload');
      console.log(`Feature flag change by ${email}`);
      return c.json({ flags: [] });
    });
    
    export default app;
    ```
    
    ## Pattern 2 — Manual JWT verification (jose library)
    
    Use when you need fine-grained control, custom claims, or are not using Hono.
    
    ```ts
    import { createRemoteJWKSet, jwtVerify } from 'jose';
    
    interface AccessPayload {
      iss: string;
      aud: string[];
      email: string;
      sub: string;
      iat: number;
      exp: number;
    }
    
    async function verifyAccessToken(
      token: string,
      teamDomain: string,    // e.g. 'your-team.cloudflareaccess.com'
      audienceTag: string
    ): Promise<AccessPayload> {
      const JWKS = createRemoteJWKSet(
        new URL(`https://${teamDomain}/cdn-cgi/access/certs`)
      );
    
      const { payload } = await jwtVerify(token, JWKS, {
        issuer: `https://${teamDomain}`,
        audience: audienceTag,
      });
    
      return payload as unknown as AccessPayload;
    }
    
    // In your Worker fetch handler:
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const token = request.headers.get('Cf-Access-Jwt-Assertion');
        if (!token) return new Response('Unauthorized', { status: 401 });
    
        try {
          const payload = await verifyAccessToken(
            token,
            env.CF_ACCESS_TEAM_DOMAIN,
            env.CF_ACCESS_AUD
          );
          // payload.email, payload.sub available
          return Response.json({ user: payload.email });
        } catch {
          return new Response('Forbidden', { status: 403 });
        }
      },
    };
    ```
    
    ## Pattern 3 — Service tokens (M2M, no human login)
    
    ```ts
    // CF Access → Service Auth → Create a service token
    // Outputs: CF-Access-Client-Id + CF-Access-Client-Secret headers
    
    // In your calling Worker/script:
    const res = await fetch('https://admin.yourdomain.com/api/internal', {
      headers: {
        'CF-Access-Client-Id': env.SERVICE_TOKEN_ID,
        'CF-Access-Client-Secret': env.SERVICE_TOKEN_SECRET,
      },
    });
    
    // In the receiving Worker — CF verifies the service token automatically,
    // no code needed. The Cf-Access-Jwt-Assertion header is still present.
    ```
    
    ## wrangler.toml (env vars for manual verification)
    
    ```toml
    [vars]
    CF_ACCESS_TEAM_DOMAIN = "your-team.cloudflareaccess.com"
    CF_ACCESS_AUD = "your-application-audience-tag-32-chars"
    ```
    
    For `@hono/cloudflare-access`, these are passed directly in code (not env vars) since the middleware signature takes the team name and aud as string literals.
    
    ## Identity provider integrations
    
    CF Access supports these IdPs out of the box — no SDK needed in your Worker:
    
    - Google Workspace (email domain or specific group)
    - GitHub (org membership)
    - Microsoft Azure AD / Entra (group-based)
    - Okta / OneLogin / SAML 2.0
    - OTP via email (no IdP account required — useful for external contractors)
    - PIN-based one-time codes
    
    Switch IdP without changing Worker code. CF handles the OIDC/SAML dance.
    
    ## Protecting specific Hono route groups
    
    ```ts
    // Layered: public API + CF-Access-protected admin
    const app = new Hono<{ Bindings: Env; Variables: CloudflareAccessVariables }>();
    
    // Public routes — no Access required
    app.get('/api/status', (c) => c.json({ ok: true }));
    app.get('/api/v1/*', publicApiHandler);
    
    // Admin routes — CF Access JWT required
    const adminAccess = cloudflareAccess('your-team', 'your-aud-tag');
    app.use('/admin/*', adminAccess);
    app.get('/admin/*', adminRouter.fetch);
    
    // Internal cron trigger — also protect via Access service token
    app.post('/internal/cron-trigger', adminAccess, cronHandler);
    ```
    
    ## Audit logs
    
    Every Access request is logged in Zero Trust → Logs → Access with:
    
    - User email
    - Timestamp
    - Allowed/denied decision
    - Policy matched
    - IP + country
    
    No code needed. Built-in compliance trail for SOC 2 / HIPAA requirements.
    
    ## Gotchas
    
    - **Local dev bypass** — `@hono/cloudflare-access` throws in local `wrangler dev` because CF cannot inject the JWT locally. Use `wrangler dev --remote` or skip Access middleware behind an `env.CF_ENV !== 'local'` guard during dev
    - **Cookie vs header** — CF Access sets both a cookie (`CF_Authorization`) and the `Cf-Access-Jwt-Assertion` header. The middleware reads the header; direct browser navigation uses the cookie. Both are verified the same way
    - **Session duration** — Access sessions last the duration you configure (default 24h). After expiry, CF prompts re-authentication transparently
    - **Audience tag is not secret** — it is the identifier for your application, not a secret. Store it as a plain var, not a Secret
    
    ## Cross-links
    
    - `[[cloudflare-lock-in-is-leverage]]` — no external auth vendor for internal tooling
    - `[[feature-flags]]` — `/admin/feature-flags` route protected by CF Access
    - `[[cf-do-rate-limiter]]` — even Access-protected routes benefit from rate limiting for abuse prevention
    
  • coolify-docker-proxmox.md 7.2 KB
    ---
    name: "Coolify, Docker, and Proxmox"
    description: "Orchestrate self-hosted services on Brian's Proxmox box via Coolify API. Deploy Docker containers, manage environment variables, restart services, and provision new services. REQUIRES USER CONFIRMATION on first use per project — ask before touching production infrastructure. 70+ services already running."
    updated: "2026-04-23"
    ---
    
    # Coolify, Docker, and Proxmox
    
    ## CRITICAL: First-Use Confirmation Required
    
    Ask before ANY Coolify action in a new project:
    
    ```
    Hey — this project needs [service/capability] which runs on your Proxmox
    box via Coolify. I'll need to:
    
      [specific action: deploy a new service / configure an existing one / etc.]
    
    This touches your production self-hosted infrastructure (70+ services).
    Want me to go ahead?
    ```
    
    Wait for explicit "yes". Re-confirm within the same project only for: new service deploys, deletes/restarts, changes to shared-service env vars, or DNS/networking modifications.
    
    ## Infrastructure Overview
    
    ### Proxmox Host
    
    - Hardware: bare metal Proxmox with ZFS storage
    - VMs: OPNsense, Ubuntu Desktop, macOS, Windows 11, Home Assistant OS, Coolify server
    - Backup: daily ZFS snapshots → R2 (3-2-1)
    - Network: VLAN segmentation, 10+ VLANs
    
    ### OPNsense
    
    - Primary firewall/router virtualized on Proxmox
    - VPN: multi-provider WireGuard + OpenVPN + Cloudflare WARP
    - DNS: Unbound with DNSSEC + DNS-over-TLS
    - ACME: Let's Encrypt via Cloudflare DNS challenge for `*.megabyte.space`
    - Authentik LDAP integration + Headscale mesh VPN
    
    ### Coolify Access
    
    - URL: `{service}.megabyte.space` (behind CF Tunnel + Authentik)
    - API: `{coolify-url}/api/v1/`
    - Token: `~/.config/emdash/coolify-token`
    - Reverse proxy: Traefik with Authentik forward-auth middleware
    - Docker-compose magic vars: `SERVICE_FQDN_*`, `SERVICE_URL_*`
    
    ### Already-Running Services
    
    | Service | Role |
    |---------|------|
    | Authentik | SSO for everything |
    | Healthchecks | Uptime monitoring |
    | OpenWebUI | AI chat interface |
    | Bolt.diy | AI website builder |
    | Dify | AI app builder |
    | Postiz | Social automation |
    | n8n | Workflow automation |
    | Sentry | Error tracking (mandatory) |
    | PostHog | Product analytics (mandatory) |
    | FireCrawl | Web scraping |
    | Listmonk | Email marketing |
    | Browserless | Headless Chrome |
    | Home Assistant | Smart home (internal) |
    
    All follow `{service}.megabyte.space`. Discoverable via Coolify API.
    
    ### Common Problems
    
    1. Healthcheck failures in Docker compose
    2. Container file permissions (9999:root pattern)
    3. Redirect loops — Cloudflare → Authentik → Service
    4. Port conflicts between containers
    5. Volume permission issues
    6. TLS handshake timeouts
    
    ## Coolify API Reference
    
    ### Authentication
    
    ```bash
    COOLIFY_TOKEN=$(cat ~/.config/emdash/coolify-token)
    COOLIFY_URL="https://coolify.megabyte.space/api/v1"
    ```
    
    ### List All Services
    
    ```bash
    curl -s "$COOLIFY_URL/services" \
      -H "Authorization: Bearer $COOLIFY_TOKEN" | jq '.[].name'
    ```
    
    ### Get Service Details
    
    ```bash
    curl -s "$COOLIFY_URL/services/{service_id}" \
      -H "Authorization: Bearer $COOLIFY_TOKEN" | jq '{name, status, fqdn}'
    ```
    
    ### Get Environment Variables
    
    ```bash
    curl -s "$COOLIFY_URL/services/{service_id}/envs" \
      -H "Authorization: Bearer $COOLIFY_TOKEN" | jq '.[].key'
    ```
    
    ### Set Environment Variable
    
    ```bash
    # CONFIRMATION REQUIRED for shared services
    curl -X POST "$COOLIFY_URL/services/{service_id}/envs" \
      -H "Authorization: Bearer $COOLIFY_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"key": "ENV_NAME", "value": "env_value", "is_build_time": false}'
    ```
    
    ### Restart Service
    
    ```bash
    # CONFIRMATION REQUIRED
    curl -X POST "$COOLIFY_URL/services/{service_id}/restart" \
      -H "Authorization: Bearer $COOLIFY_TOKEN"
    ```
    
    ### Deploy New Service
    
    ```bash
    # CONFIRMATION REQUIRED — always ask first
    curl -X POST "$COOLIFY_URL/services" \
      -H "Authorization: Bearer $COOLIFY_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "service-name",
        "type": "docker-compose",
        "docker_compose": "version: \"3\"\nservices:\n  app:\n    image: service:latest\n    ports:\n      - \"8080:8080\"",
        "server_id": 1
      }'
    ```
    
    ### Check Service Health
    
    ```bash
    curl -s "$COOLIFY_URL/services/{service_id}" \
      -H "Authorization: Bearer $COOLIFY_TOKEN" | jq '.status'
    ```
    
    ## When to Use Coolify vs Cloudflare
    
    | Need | Use Cloudflare | Use Coolify |
    |------|---------------|-------------|
    | API endpoints | Workers (Hono) | — |
    | Static sites | Workers Static Assets | — |
    | SQLite database | D1 (1TB, global replicas) | — |
    | Object storage | R2 | — |
    | Key-value cache | KV | — |
    | Stateful connections | Durable Objects | — |
    | Docker containers | CF Containers (GA, DO-based) | Coolify (more control, custom networking) |
    | Sandboxed execution | CF Sandboxes (GA) or Dynamic Workers | — |
    | PostgreSQL | Neon (managed) | Coolify (self-hosted) |
    | Long-running processes | Workflows v2 + Queues | Coolify |
    | Full-stack apps (Django, Rails, etc.) | CF Containers | Coolify (existing infra) |
    | Services needing persistent disk | CF Containers | Coolify |
    | Services needing >128MB RAM | CF Containers | Coolify |
    | Custom networking | CF Mesh | Coolify (complex VLAN) |
    | Email marketing | CF Email Service (beta) | Listmonk on Coolify |
    
    **Rule** — Default to Cloudflare. CF Containers GA eliminates most Coolify use cases for new projects. Use Coolify for existing 70+ services and complex self-hosted stacks.
    
    ## Docker Compose Patterns
    
    ### Simple Service
    
    ```yaml
    version: "3"
    services:
      app:
        image: service/image:latest
        restart: unless-stopped
        environment:
          - DATABASE_URL=postgresql://...
          - SECRET_KEY=${SECRET_KEY}
        ports:
          - "8080:8080"
        volumes:
          - data:/app/data
    volumes:
      data:
    ```
    
    ### Service with PostgreSQL
    
    ```yaml
    version: "3"
    services:
      app:
        image: service/image:latest
        restart: unless-stopped
        depends_on: [db]
        environment:
          - DATABASE_URL=postgresql://user:pass@db:5432/app
      db:
        image: postgres:16-alpine
        restart: unless-stopped
        environment:
          - POSTGRES_USER=user
          - POSTGRES_PASSWORD=pass
          - POSTGRES_DB=app
        volumes:
          - pgdata:/var/lib/postgresql/data
    volumes:
      pgdata:
    ```
    
    ## Disaster Recovery
    
    - Coolify auto-backs up configurations
    - PostgreSQL: `pg_dump` via cron to R2
    - Volumes: periodic tar to R2
    - Env vars: exported and stored encrypted
    
    ```bash
    # 1. Restore Coolify from backup
    # 2. Re-deploy services from docker-compose configs
    # 3. Restore database from pg_dump
    # 4. Restore volumes from R2 tarballs
    # 5. Verify all services healthy
    ```
    
    See `08/backup-and-disaster-recovery` for the full single-zip restore plan.
    
    ## Troubleshooting
    
    | Issue | Fix |
    |-------|-----|
    | Service unreachable | Check `curl $COOLIFY_URL/services/{id}` status |
    | Container restarting | Check logs — `docker logs {container_id}` via Coolify UI |
    | Out of memory | Scale up Proxmox VM or optimize service config |
    | Disk full | Clean Docker — `docker system prune -a` |
    | SSL cert expired | Coolify auto-renews via Let's Encrypt; check Traefik logs |
    | API timeout | Coolify may be overloaded; check Proxmox CPU/RAM |
    
    ## What This Skill Owns
    
    - Coolify API interaction
    - Docker service deployment and management
    - Self-hosted service orchestration
    - Proxmox infrastructure awareness
    - When-to-use-Coolify decision logic
    
  • drizzle-orm-and-migrations.md 6.3 KB
    ---
    name: "Drizzle ORM and Migrations"
    description: "Drizzle ORM v1.0 (beta.2) as the database abstraction layer for D1 (SQLite) and Neon (PostgreSQL). RQBv2, 10x faster introspection, schema-first design with auto-generated migrations, type-safe queries, and the Drizzle → D1/Neon setup pattern. Covers schema conventions, relation patterns, migration workflow, and seed data."
    updated: "2026-04-23"
    ---
    
    # Drizzle ORM and Migrations
    
    ## Why Drizzle (v1.0.0-beta.2, passed Prisma in downloads)
    
    - Type-safe queries, 5KB bundle (vs Prisma 40KB+), zero-overhead SQL
    - RQBv2 — 363 commits, 9K+ tests, relational query builder rewrite
    - 10x schema introspection speed (10s → <1s)
    - Schema defined in TypeScript (single source of truth)
    - Auto-generated migrations via `drizzle-kit`
    - Works with D1 (SQLite), Neon (PostgreSQL), MSSQL (new)
    - Edge runtime compatible — perfect for Workers
    
    ## Setup
    
    ### Install
    
    ```bash
    npm install drizzle-orm
    npm install -D drizzle-kit
    ```
    
    ### drizzle.config.ts
    
    ```typescript
    import { defineConfig } from 'drizzle-kit';
    
    export default defineConfig({
      schema: './src/db/schema.ts',
      out: './drizzle',
      dialect: 'sqlite', // or 'postgresql' for Neon
      driver: 'd1-http',
    });
    ```
    
    ### Worker Binding
    
    ```typescript
    import { drizzle } from 'drizzle-orm/d1';
    import * as schema from './db/schema';
    
    app.use('*', async (c, next) => {
      c.set('db', drizzle(c.env.DB, { schema }));
      await next();
    });
    ```
    
    ## Schema Conventions
    
    ### Standard Columns (Every Table)
    
    ```typescript
    import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
    import { sql } from 'drizzle-orm';
    
    // ULID for ordered primary keys (better D1 insert performance)
    // See: https://github.com/ulid/spec
    export const users = sqliteTable('users', {
      id: text('id').primaryKey(), // ULID
      email: text('email').notNull().unique(),
      name: text('name').notNull(),
      role: text('role', { enum: ['user', 'admin'] }).default('user').notNull(),
      createdAt: text('created_at').default(sql`(datetime('now'))`).notNull(),
      updatedAt: text('updated_at').default(sql`(datetime('now'))`).notNull(),
      deletedAt: text('deleted_at'), // Soft delete — null means active
    });
    ```
    
    ### Naming Rules
    
    - **Tables** — plural snake_case (`users`, `blog_posts`, `donation_records`)
    - **Columns** — snake_case (`created_at`, `stripe_customer_id`)
    - **TypeScript** — camelCase (`createdAt`, `stripeCustomerId`) — Drizzle maps automatically
    - **Indexes** — `idx_{table}_{column}` (`idx_users_email`)
    - **Foreign keys** — `{referenced_table}_id` (`user_id`, `post_id`)
    
    ### Relations
    
    ```typescript
    import { relations } from 'drizzle-orm';
    
    export const posts = sqliteTable('posts', {
      id: text('id').primaryKey(),
      title: text('title').notNull(),
      slug: text('slug').notNull().unique(),
      content: text('content').notNull(),
      authorId: text('author_id').notNull().references(() => users.id),
      publishedAt: text('published_at'),
      createdAt: text('created_at').default(sql`(datetime('now'))`).notNull(),
    });
    
    export const usersRelations = relations(users, ({ many }) => ({
      posts: many(posts),
    }));
    
    export const postsRelations = relations(posts, ({ one }) => ({
      author: one(users, { fields: [posts.authorId], references: [users.id] }),
    }));
    ```
    
    ### Indexes
    
    ```typescript
    import { index, uniqueIndex } from 'drizzle-orm/sqlite-core';
    
    export const posts = sqliteTable('posts', {
      // ... columns
    }, (table) => ({
      slugIdx: uniqueIndex('idx_posts_slug').on(table.slug),
      authorIdx: index('idx_posts_author').on(table.authorId),
      publishedIdx: index('idx_posts_published').on(table.publishedAt),
    }));
    ```
    
    ## Migration Workflow
    
    ### Generate Migration
    
    ```bash
    npx drizzle-kit generate
    # Creates: drizzle/0001_initial.sql
    ```
    
    ### Apply to D1
    
    ```bash
    npx wrangler d1 migrations apply DB --local  # Test locally first
    npx wrangler d1 migrations apply DB          # Apply to production
    ```
    
    ### Migration Best Practices
    
    - Always generate, never hand-write SQL
    - Test locally before applying to production
    - Never rename columns in production (add new, migrate data, drop old)
    - Add indexes AFTER initial data load for speed
    - Use `PRAGMA optimize` after bulk writes
    
    ## Common Query Patterns
    
    ### Select with Relations
    
    ```typescript
    const postsWithAuthor = await db.query.posts.findMany({
      with: { author: true },
      where: isNotNull(posts.publishedAt),
      orderBy: [desc(posts.publishedAt)],
      limit: 10,
    });
    ```
    
    ### Insert
    
    ```typescript
    import { ulid } from 'ulid';
    
    await db.insert(users).values({
      id: ulid(),
      email: parsed.email,
      name: parsed.name,
    });
    ```
    
    ### Update
    
    ```typescript
    await db.update(users)
      .set({ name: parsed.name, updatedAt: sql`datetime('now')` })
      .where(eq(users.id, userId));
    ```
    
    ### Soft Delete
    
    ```typescript
    await db.update(users)
      .set({ deletedAt: sql`datetime('now')` })
      .where(eq(users.id, userId));
    
    // Always filter out soft-deleted in queries
    const activeUsers = await db.query.users.findMany({
      where: isNull(users.deletedAt),
    });
    ```
    
    ### Transaction
    
    ```typescript
    await db.batch([
      db.insert(donations).values({ id: ulid(), amount: 5000, userId }),
      db.update(campaigns).set({ raised: sql`raised + 5000` }).where(eq(campaigns.id, campaignId)),
    ]);
    ```
    
    ## Seed Data
    
    ```typescript
    // src/db/seed.ts — run after migrations
    async function seed(db: DrizzleD1Database) {
      await db.insert(users).values([
        { id: ulid(), email: 'brian@megabyte.space', name: 'Brian Zalewski', role: 'admin' },
      ]);
    }
    ```
    
    ## Neon (PostgreSQL) Variant
    
    When D1 isn't enough (complex joins, full-text search, >1TB, RLS):
    
    ```typescript
    import { drizzle } from 'drizzle-orm/neon-http';
    import { neon } from '@neondatabase/serverless';
    
    const sql = neon(env.DATABASE_URL);
    const db = drizzle(sql, { schema });
    ```
    
    Schema uses `pgTable` instead of `sqliteTable`, identity columns (not serial) for IDs, and `timestamp` instead of `text` for dates. Use `$inferSelect`/`$inferInsert` for type derivation. Zod integration via `createInsertSchema`/`createSelectSchema`.
    
    ## D1 Notes (2026)
    
    - Global read replication (beta) — routes reads to nearest replica, reduces latency 40-60%
    - Storage — 1TB per account (paid), Time Travel 30-day PIT recovery
    - PRAGMA optimize support for query performance
    - Does NOT support BEGIN transactions — use batch API instead
    - Prepared statements for repeated queries
    - `node:fs` and Web File System APIs now available in Workers
    
  • dynamic-sitemap-from-d1.md 9.6 KB
    ---
    skill: dynamic-sitemap-from-d1
    version: 1.0.0
    tags: [cloudflare, d1, seo, sitemap, workers, hono]
    cross-links: [pseo-templates, hono-api, cf-d1-patterns]
    ---
    
    # Dynamic Sitemap from D1
    
    ## Why Split Sitemaps
    
    - Google hard limit: 50k URLs / 50MB per sitemap file — split by content type, never alphabetically
    - Sitemap index (`sitemap.xml`) references child sitemaps; Googlebot fetches each independently
    - Split pattern: `sitemap-static.xml` (hand-authored routes), `sitemap-blog.xml`, `sitemap-products.xml`, `sitemap-pseo.xml`, `sitemap-locales.xml`
    - `<lastmod>` from D1 `updated_at` column — do NOT fake with today's date (misleads Googlebot crawl budget)
    
    ## D1 Schema Requirements
    
    ```sql
    -- every content table needs these columns for sitemap generation
    ALTER TABLE blog_posts ADD COLUMN slug TEXT UNIQUE NOT NULL;
    ALTER TABLE blog_posts ADD COLUMN updated_at INTEGER NOT NULL DEFAULT (unixepoch());
    ALTER TABLE blog_posts ADD COLUMN sitemap_priority REAL DEFAULT 0.7;
    ALTER TABLE blog_posts ADD COLUMN sitemap_changefreq TEXT DEFAULT 'weekly';
    ALTER TABLE blog_posts ADD COLUMN published INTEGER DEFAULT 0;
    
    -- same pattern for products, pseo_pages, locales
    ```
    
    - Index on `(published, updated_at DESC)` — every sitemap query filters + sorts on both
    - `updated_at` is Unix epoch integer, not ISO string — multiply by 1000 for JS `Date`
    
    ## Sitemap Index Route
    
    ```ts
    import { Hono } from 'hono';
    
    type Env = { DB: D1Database; CACHE: Cache; SITE_URL: string };
    
    const app = new Hono<{ Bindings: Env }>();
    
    const SITEMAP_NAMES = ['static', 'blog', 'products', 'pseo', 'locales'] as const;
    
    app.get('/sitemap.xml', async (c) => {
      const cacheKey = new Request(`${c.env.SITE_URL}/sitemap.xml`);
      const cached = await caches.default.match(cacheKey);
      if (cached) return cached;
    
      const lastmods = await Promise.all(
        SITEMAP_NAMES.map(async (name) => {
          const row = await c.env.DB.prepare(
            `SELECT MAX(updated_at) as lm FROM sitemap_meta WHERE name = ?`
          ).bind(name).first<{ lm: number | null }>();
          return {
            name,
            lastmod: row?.lm
              ? new Date(row.lm * 1000).toISOString().split('T')[0]
              : new Date().toISOString().split('T')[0],
          };
        })
      );
    
      const xml = `<?xml version="1.0" encoding="UTF-8"?>
    <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    ${lastmods.map(({ name, lastmod }) => `  <sitemap>
        <loc>${c.env.SITE_URL}/sitemap-${name}.xml</loc>
        <lastmod>${lastmod}</lastmod>
      </sitemap>`).join('\n')}
    </sitemapindex>`;
    
      const response = new Response(xml, {
        headers: {
          'Content-Type': 'application/xml; charset=utf-8',
          'Cache-Control': 'public, max-age=3600, s-maxage=3600',
          'Content-Encoding': 'identity',
        },
      });
      c.executionCtx.waitUntil(caches.default.put(cacheKey, response.clone()));
      return response;
    });
    ```
    
    ## Child Sitemap Route (Paginated from D1)
    
    ```ts
    app.get('/sitemap-blog.xml', async (c) => {
      const cacheKey = new Request(`${c.env.SITE_URL}/sitemap-blog.xml`);
      const cached = await caches.default.match(cacheKey);
      if (cached) return cached;
    
      // D1 cursor-based pagination for >1000 rows
      const rows: { slug: string; updated_at: number; priority: number; changefreq: string }[] = [];
      let offset = 0;
      const PAGE = 500;
      while (true) {
        const page = await c.env.DB.prepare(
          `SELECT slug, updated_at, sitemap_priority as priority, sitemap_changefreq as changefreq
           FROM blog_posts WHERE published = 1
           ORDER BY updated_at DESC LIMIT ? OFFSET ?`
        ).bind(PAGE, offset).all<typeof rows[0]>();
        rows.push(...page.results);
        if (page.results.length < PAGE) break;
        offset += PAGE;
      }
    
      const urlTags = rows.map(({ slug, updated_at, priority, changefreq }) => `  <url>
        <loc>${c.env.SITE_URL}/blog/${slug}</loc>
        <lastmod>${new Date(updated_at * 1000).toISOString().split('T')[0]}</lastmod>
        <changefreq>${changefreq}</changefreq>
        <priority>${priority}</priority>
      </url>`).join('\n');
    
      const xml = `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    ${urlTags}
    </urlset>`;
    
      const response = new Response(xml, {
        headers: {
          'Content-Type': 'application/xml; charset=utf-8',
          'Cache-Control': 'public, max-age=3600, s-maxage=3600',
        },
      });
      c.executionCtx.waitUntil(caches.default.put(cacheKey, response.clone()));
      return response;
    });
    ```
    
    - Reuse this pattern for `sitemap-products.xml`, `sitemap-pseo.xml`, `sitemap-locales.xml` — change the table + slug path
    - PAGE=500 keeps each D1 round-trip under the 100ms time budget; loop adds round-trips only when needed
    
    ## Cache Invalidation
    
    - On content publish: `await caches.default.delete(new Request(\`${SITE_URL}/sitemap-blog.xml\`))` — invalidate child only
    - Also delete the sitemap index so `<lastmod>` reflects the new publish timestamp
    - Pattern: fire invalidation in the same write handler via `ctx.waitUntil` — non-blocking, no extra latency
    - CF Cache API is per-datacenter, not global CDN purge — for zone-wide purge use `fetch('https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache', { method: 'POST', body: JSON.stringify({ files: [...] }) })`
    
    ## Static Sitemap Segment
    
    ```ts
    const STATIC_URLS = [
      { loc: '/', priority: '1.0', changefreq: 'daily' },
      { loc: '/about', priority: '0.9', changefreq: 'monthly' },
      { loc: '/pricing', priority: '0.9', changefreq: 'weekly' },
      { loc: '/blog', priority: '0.8', changefreq: 'daily' },
      { loc: '/contact', priority: '0.7', changefreq: 'yearly' },
    ];
    
    app.get('/sitemap-static.xml', (c) => {
      const xml = `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    ${STATIC_URLS.map(({ loc, priority, changefreq }) => `  <url>
        <loc>${c.env.SITE_URL}${loc}</loc>
        <changefreq>${changefreq}</changefreq>
        <priority>${priority}</priority>
      </url>`).join('\n')}
    </urlset>`;
      return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=86400' } });
    });
    ```
    
    - Priority scale: 1.0 homepage, 0.9 top-level marketing, 0.8 feature pages, 0.7 content, 0.5 utility
    - No D1 query, no cache API needed — inline constant, cached by CF edge automatically via `Cache-Control`
    
    ## Gzip Encoding
    
    ```ts
    // only apply when payload > 1MB; check Accept-Encoding first
    app.get('/sitemap-pseo.xml', async (c) => {
      // ... build xml string ...
      const acceptsGzip = c.req.header('Accept-Encoding')?.includes('gzip') ?? false;
      if (acceptsGzip && xml.length > 1_000_000) {
        const encoder = new TextEncoderStream();
        const compressor = new CompressionStream('gzip');
        const stream = encoder.readable.pipeThrough(compressor);
        const writer = encoder.writable.getWriter();
        writer.write(xml);
        writer.close();
        return new Response(stream, {
          headers: {
            'Content-Type': 'application/xml',
            'Content-Encoding': 'gzip',
            'Cache-Control': 'public, max-age=3600',
          },
        });
      }
      return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } });
    });
    ```
    
    - `CompressionStream('gzip')` is available in Workers runtime — no npm dep needed
    - Under 1MB: compression overhead isn't worth it; Googlebot handles plaintext fine
    
    ## wrangler.toml Routes
    
    ```toml
    [[routes]]
    pattern = "/sitemap*.xml"
    zone_name = "yourdomain.com"
    
    [[routes]]
    pattern = "/robots.txt"
    zone_name = "yourdomain.com"
    ```
    
    - Single `sitemap*.xml` wildcard catches index + all children — no per-child route entry needed
    - No KV binding required — CF Cache API is available to all Workers by default
    
    ## Robots.txt Integration
    
    ```ts
    app.get('/robots.txt', (c) => {
      return c.text(`User-agent: *\nAllow: /\nSitemap: ${c.env.SITE_URL}/sitemap.xml\n`);
    });
    ```
    
    - Point `Sitemap:` at the index URL only — never individual children; GSC discovers children via the index
    - Submit only `sitemap.xml` in Google Search Console → Indexing → Sitemaps
    
    ## Nightly Regeneration Cron
    
    ```ts
    // wrangler.toml: [triggers] crons = ["0 2 * * *"]
    export default {
      async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
        const SITEMAPS = ['static', 'blog', 'products', 'pseo', 'locales'];
        await Promise.all(
          SITEMAPS.map((name) =>
            caches.default.delete(new Request(`${env.SITE_URL}/sitemap-${name}.xml`))
          )
        );
        await caches.default.delete(new Request(`${env.SITE_URL}/sitemap.xml`));
        // warm the cache immediately after purge
        await Promise.all(
          SITEMAPS.map((name) => fetch(`${env.SITE_URL}/sitemap-${name}.xml`))
        );
        await fetch(`${env.SITE_URL}/sitemap.xml`);
        // ping Google
        await fetch(`https://www.google.com/ping?sitemap=${encodeURIComponent(env.SITE_URL + '/sitemap.xml')}`);
      },
    };
    ```
    
    - Run at 2am UTC — low-traffic window, D1 queries won't contend with user requests
    - Warm cache immediately after purge so first Googlebot hit is already cached
    
    ## Production Checklist
    
    - D1 query must filter `published = 1` — never leak draft URLs into sitemaps
    - `<lastmod>` must be ISO 8601 date only (`YYYY-MM-DD`), not datetime — Googlebot prefers date-only
    - Validate with `https://www.xml-sitemaps.com/validate-xml-sitemap.html` before going live
    - Confirm no duplicate `<loc>` values across child sitemaps — Googlebot deduplicates but it wastes crawl budget
    - Monitor errors in Google Search Console → Indexing → Sitemaps after first submission
    
    ## See Also
    
    - `[[pseo-templates]]` — pSEO pages slot into `sitemap-pseo.xml` using the same paginated D1 pattern
    - `[[hono-api]]` — Hono routing conventions and middleware patterns
    - `[[cf-d1-patterns]]` — D1 pagination, cursor patterns, and index design
    
  • enterprise-multi-tenancy.md 5.7 KB
    ---
    name: "Enterprise Multi-Tenancy"
    description: "DB-per-tenant D1, RBAC/ABAC authorization (Cerbos/OPA), audit logging (R2 WORM), SSO/SCIM (Clerk Enterprise Connections), data residency (D1 jurisdiction), per-tenant rate limiting (CF RateLimit binding), white-labeling (CF for SaaS), admin impersonation, data portability (EU Data Act)."
    updated: "2026-04-23"
    ---
    
    # Enterprise Multi-Tenancy
    
    ## Tenant Isolation (D1-per-Tenant)
    
    - Each tenant gets its own D1 database
    - Create on signup — `wrangler d1 create tenant-${orgId} --jurisdiction eu`
    - Bind dynamically via `env.DB_${orgId}` or D1 binding per request using org lookup table in shared D1
    
    ### Benefits
    
    - Data isolation by default
    - Per-tenant backup/restore
    - Jurisdiction compliance
    - No row-level security bugs
    - Shared D1 for cross-tenant data (plans, features, billing)
    
    ### Migration Strategy
    
    - Run `drizzle-kit push` against each tenant DB on deploy
    - Track schema version in shared DB
    - Batch migrate with `wrangler d1 execute --batch`
    - Rollback — D1 Time Travel per-tenant PIT recovery
    
    ## Authorization (RBAC + ABAC)
    
    - **Clerk Organizations** — built-in org membership, roles (admin/member/viewer), permissions. `auth().orgRole` in middleware.
    - **Complex policies** — Cerbos (policy-as-code, sidecar on CF Container) or OPA (Rego policies, compile to WASM for Workers)
    - **Pattern** — Clerk authenticates → org context → Cerbos/OPA authorizes per-resource
    
    ### Default Roles
    
    - **owner** — full CRUD + billing + member mgmt
    - **admin** — CRUD + member mgmt
    - **member** — CRUD own resources
    - **viewer** — read-only
    - **billing** — billing-only
    - Custom roles via Clerk Dashboard or API
    
    ### ABAC Attributes
    
    - `org.plan`, `resource.owner`, `user.role`, `request.ip`, `time.hour`
    - Example — "members can edit documents they own, admins can edit all, viewers can only read, free-tier orgs limited to 10 documents"
    
    ## Audit Logging (R2 WORM)
    
    - Every mutating API call — `{ timestamp, actor, orgId, action, resource, before, after, ip, userAgent }`
    - Write to R2 with WORM (Write Once Read Many) lifecycle policy — immutable for compliance
    - Batch via `ctx.waitUntil()` to avoid latency
    - Query via R2 SQL (when GA) or export to BigQuery/ClickHouse
    
    ### Retention
    
    - 7 years financial (SOX)
    - 6 years GDPR
    - Configurable per-tenant
    - R2 lifecycle rules for auto-archival
    - Sentry breadcrumbs for real-time error context
    
    ## SSO/SCIM (Clerk Enterprise Connections)
    
    - **Clerk Enterprise Connections** — SAML/OIDC SSO, SCIM provisioning
    - Per-org SSO config via dashboard or API
    - **SCIM endpoints** — auto-sync user create/update/deactivate from IdP (Okta/Azure AD/OneLogin)
    - **JIT provisioning** — first SSO login auto-creates Clerk user + org membership
    - **Enterprise plan gate** — SSO/SCIM features behind `enterprise` plan check via Stripe Entitlements API. Show "Contact Sales" on pricing page.
    
    ## Data Residency (D1 Jurisdiction)
    
    - `--jurisdiction eu` — restricts D1 to EU data centers (GDPR Art. 44)
    - `--jurisdiction fedramp` — for US government
    - Per-tenant jurisdiction stored in shared config DB
    - Workers route to correct D1 binding based on org jurisdiction
    - Display data location in tenant settings
    
    ## Per-Tenant Rate Limiting
    
    - CF Rate Limiting binding — `env.RATE_LIMITER.limit({ key: orgId })`
    - **Tier-based limits** — free=100 req/min, pro=1000 req/min, enterprise=custom
    - Return `429` with `Retry-After` header
    - Track in PostHog — `rate_limit_hit` event with `orgId`
    
    ## White-Labeling (CF for SaaS)
    
    - Cloudflare for SaaS — tenants bring custom domains
    - `PUT /zones/{zone_id}/custom_hostnames` to register
    - SSL auto-provisioned
    - Workers routes match custom hostname → resolve tenant → serve branded experience
    - Per-tenant — logo, colors, favicon, email templates (Resend per-domain)
    
    ## Admin Impersonation
    
    - Clerk `impersonate()` — admin logs in as any user within their org
    - Audit-logged (impersonator ID stored)
    - **Visual indicator** — persistent banner "Acting as [user]"
    - Auto-expire after 1hr
    - Requires `org:admin` permission
    - Disable for billing/payment actions
    
    ## Data Portability (EU Data Act 2025)
    
    - **Export endpoint** — `GET /api/org/{orgId}/export` returns ZIP of all tenant data (JSON + media)
    - **Include** — users, content, settings, billing history, audit logs
    - **Exclude** — system internals, other tenants
    - GDPR Art. 20 right to portability
    - EU Data Act (Sept 2025) — mandatory machine-readable export for all SaaS
    - **Deletion endpoint** — `DELETE /api/org/{orgId}` with 30-day grace period
    
    ## IP Allowlisting
    
    - Enterprise customers — restrict API access to approved IP ranges
    - Store per-org in D1 (`org_ip_allowlist` table)
    - Check in auth middleware after Clerk JWT verification
    - `CF-Connecting-IP` header for real client IP (CF proxied)
    - Return 403 for non-allowlisted IPs
    - **Admin UI** — org settings → Security → IP Allowlist (add/remove CIDR ranges)
    - Bypass for Clerk webhook IPs and health endpoints
    
    ## Compliance Certification Display
    
    - SOC 2 Type II / HIPAA / GDPR badges on pricing page + footer
    - **Trust page at `/trust`** — certifications, data processing agreements (DPA), subprocessor list, security practices, uptime history
    - Auto-generate from Vanta/Drata API if available, otherwise static markdown
    - Link DPA download (PDF in R2)
    
    ## Zero-Human-Loop Automation
    
    - **Tenant creation** — Clerk org webhook → D1 create → Stripe customer create → welcome email (Resend) → PostHog identify. All automated.
    - **Tenant deletion** — grace period webhook → data export to R2 → D1 delete → Stripe cancel → purge confirmation
    - **Plan upgrades** — Stripe webhook → Entitlements check → feature unlock → notification
    - No manual provisioning at any step
    
  • heartbeat-polling.md 2.5 KB
    ---
    name: "heartbeat-polling"
    description: "CF Workflows heartbeat polling pattern for long-running container jobs — avoids 25min step timeout"
    updated: "2026-04-24"
    ---
    
    # Heartbeat Polling for Cloudflare Workflows
    
    Cloudflare Workflows have a ~25min per-step timeout and ~1MB step output limit. Long-running jobs (AI builds, batch processing) easily exceed both. Heartbeat polling solves both constraints.
    
    ## Pattern
    
    ### Step 1: start-job
    
    - POST `/build` to container with payload
    - Container starts async work, returns `{ jobId }` immediately (~1s)
    
    ### Steps 2..N: heartbeat-{i} (loop, max 120 × 30s = 60min)
    
    - Sleep 30s → GET `/status?jobId=X`
    - Returns `{ status, step, elapsed, fileCount, error }`
    - Each poll is a tiny step (~5s) — never hits timeout
    - Break when `status !== 'running'`
    
    ### Step N+1: fetch-result
    
    - GET `/result?jobId=X`
    - Upload large output to R2 INSIDE the step (avoids output limit)
    - Return only metadata (version string, file count)
    
    ## Why This Works
    
    - Each heartbeat step — 30s sleep + 5s fetch = 35s total. Well under 25min limit.
    - Step output — ~200 bytes (status JSON). Well under 1MB.
    - Total polling — 120 × 30s = 60min max. Configurable via `MAX_POLLS` constant.
    
    ## Stable Container IDs
    
    - CF Containers use Durable Object IDs
    - Use `idFromName(stableKey)` so all steps talk to the SAME container instance
    - **Key pattern** — `${slug}-build-${siteId.slice(0,8)}`
    - NEVER create new IDs per step — that spawns new containers
    
    ## Step Output Management
    
    - Large data (files, images) must NOT be returned as step output
    - Upload to R2/KV inside the step, return only a reference key
    - Keeps every step under 1MB and avoids serialization overhead
    
    ## Container HTTP Server
    
    - Minimal Node.js HTTP server on port 8080
    - Three endpoints:
      - `POST /build` — start async
      - `GET /status` — poll
      - `GET /result` — fetch + cleanup
    - Job store is in-memory (container lives for the duration of one build)
    - Container cleans up build dir after `/result` is fetched
    
    ## Timeout Budgeting
    
    - Container timeout = `timeoutMin × 60000` (default 45min)
    - Workflow polls up to `MAX_POLLS × POLL_INTERVAL` (default 60min)
    - Always set workflow budget > container budget so the workflow can detect container timeout vs. still-running
    - Log elapsed time in heartbeats for debugging
    
    ## Error Handling
    
    - Container crash → `/status` returns error or times out → workflow marks site as error
    - Build timeout → `MAX_POLLS` exceeded → workflow marks error
    - `/result` with 0 files → treated as error even if exit code = 0
    
  • mcp-and-cloud-integrations.md 7.6 KB
    ---
    name: "MCP and Cloud Integrations"
    description: "Connect all available MCP servers, cloud APIs, and SaaS integrations. Auto-discover secrets from shared pool, Coolify, and local configs. Integrate Slack, Discord, Twilio, Zapier, Cal.com, and all de-facto standard services. Promote aggressive use of AI APIs (OpenAI, Workers AI, Ideogram) and multimedia APIs for rich product experiences."
    updated: "2026-04-23"
    ---
    
    # MCP and Cloud Integrations
    
    ## MCP Server Discovery
    
    ### Scan Locations
    
    1. `~/.claude/settings.json` — global MCP
    2. `~/.claude/projects/*/settings.json` — per-project
    3. `~/.claude/plugins/` — installed plugins
    4. `~/.config/docker/mcp/` — Docker catalog
    5. Project `.mcp.json` or `.mcp/` — local project
    
    ### Priority-Ranked Servers
    
    | # | Server | Tools | Status | Cost |
    |---|--------|-------|--------|------|
    | 1 | Cloudflare | Workers, D1, R2, KV, DNS (2500+ endpoints via 2 tools) | Connected | Free |
    | 2 | Playwright | Browser automation, screenshots | Connected | Free |
    | 3 | Stripe | Customers, subscriptions, invoices | Connected | Free |
    | 4 | GitHub | Repos, PRs, issues, Actions | Auto | Free |
    | 5 | Neon Postgres | Projects, branches, SQL | When DB needed | Free tier |
    | 6 | Resend | Send emails, contacts, domains | Auto | Free (3K/mo) |
    | 7 | PostHog | Analytics, feature flags | Auto | Free (1M/mo) |
    | 8 | Sentry | Issues, events, stack traces | Auto | Free (5K/mo) |
    | 9 | Google Analytics | GA4 reports, dimensions | When GA4 set up | Free |
    | 10 | n8n | Workflows, expose as tools | When automation needed | Free (self-hosted) |
    | 11 | Google (Gmail, Cal, Drive) | Email, scheduling, files | Ask first | Free |
    | 12 | Composio | 300+ connectors | Ask first | Free tier |
    
    **Key insight** — Cloudflare MCP uses 2 tools + <1K tokens for 2,500+ endpoints (Code Mode).
    
    ### MCP Config Commands
    
    ```bash
    claude mcp add neon --transport http --url https://mcp.neon.tech
    claude mcp add posthog --transport http --url https://mcp.posthog.com
    claude mcp add sentry --transport http --url https://mcp.sentry.dev/mcp
    claude mcp add github --transport http --url https://api.githubcopilot.com/mcp
    claude mcp add google-workspace -- npx -y @taylorwilsdon/google_workspace_mcp
    ```
    
    ### Connected in This Environment
    
    - **Built-In (Claude AI OAuth)** — Cloudflare, Stripe, Gmail, Google Calendar, Google Drive, Slack, Canva, IFTTT
    - **Self-Hosted** — Coolify, Firecrawl, Postiz, WordPress, Home Assistant, DeepSeek, n8n, Notion, Supermemory, Plane, Omi
    - **Developer Tools** — Playwright, GitHub, Sequential Thinking, Computer Use, PostHog, Sentry
    - **Resend MCP (Apr 7, 2026)** — official MCP server published at `github.com/resend/resend-mcp`. Self-hosted: run locally (`http://127.0.0.1:3000/mcp` is the streamable-HTTP endpoint), authenticate per-client with your Resend API key as a Bearer header. Wire into Claude Code with `claude mcp add resend --transport http http://127.0.0.1:3000/mcp --header "Authorization: Bearer re_xxxxxxxxx"`. Tool coverage spans 10 groups: emails, contacts, broadcasts, domains, webhooks, segments, topics, contact properties, API keys, received emails — full Resend API surface. Use for transactional email automation, contact management, domain verification — all from Claude Code.
    
    ### Agent Interop Protocols
    
    - **MCP (Model Context Protocol)** — tool access for AI agents. 97M+ monthly SDK downloads. Donated to Linux Foundation AAIF (Dec 2025). Standard for connecting AI models to external tools/data.
    - **A2A (Agent-to-Agent, Google)** — agent discovery and coordination across org boundaries. Donated to Linux Foundation. Enables agents to find, authenticate, and delegate tasks to other agents. ACP (Cisco/LangChain agent commerce) merged into A2A (Aug 2025).
    - **Enterprise stack** — MCP (tool access) + A2A (agent coordination) = complete agent interop. MCP for connecting to services, A2A for multi-agent orchestration across teams/orgs.
    
    ### MCP → Skill Mapping
    
    - Cloudflare → `08-deploy`
    - Playwright → `07-quality`
    - Square → `13/square-payments` (donations/SMB default)
    - Stripe → `13/stripe-billing` (SaaS subs/enterprise only)
    - GitHub → `08/ci-cd-pipeline`
    - Coolify → `05/mcp-and-cloud-integrations`
    - Firecrawl → `03/competitive-analysis`
    - Postiz → `09/social-automation`
    - n8n → `06/webhook-system`
    - Gmail → `09/email-templates`
    - WordPress → `06/blog-and-content-engine`
    - Notion → `09/documentation-and-codebase-hygiene`
    - Sentry → `13-observability`
    - PostHog → `13-observability`
    - Computer Use → `07-quality`
    - Plane → `03-planning`
    
    ## Secret Hygiene
    
    - 12 MCP servers have inline secrets
    - Migrate to chezmoi at `~/.local/share/chezmoi/home/.chezmoitemplates/secrets-macbook-pro/`
    - Never store secrets in markdown, CLAUDE.md, skill files, or git-tracked configs
    
    ## Secrets Discovery (check all, merge)
    
    1. Project `.env.local`
    2. Project `.env`
    3. `rare-chefs/.env.local` (master)
    4. `~/.config/emdash/` (stored tokens)
    5. Coolify API
    6. Claude MCP configs
    7. Prompt user (ONCE per key, then store)
    
    ### Key Categories
    
    | Category | Keys |
    |----------|------|
    | AI | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `IDEOGRAM_API_KEY` |
    | Cloud | `CLOUDFLARE_API_TOKEN`, `CF_ZONE_ID` |
    | Payments (donations/SMB default) | `SQUARE_ACCESS_TOKEN`, `SQUARE_LOCATION_ID`, `SQUARE_APPLICATION_ID`, `SQUARE_ENVIRONMENT`, `SQUARE_WEBHOOK_SIGNATURE_KEY` |
    | Payments (SaaS subs / enterprise) | `STRIPE_API_KEY`, `STRIPE_WEBHOOK_SECRET` |
    | Email | `RESEND_API_KEY` |
    | Auth | `CLERK_SECRET_KEY` |
    | Analytics | `POSTHOG_API_KEY`, `SENTRY_DSN`, `GA4_MEASUREMENT_ID` |
    | Communication | `SLACK_WEBHOOK_URL`, `DISCORD_WEBHOOK_URL`, `TWILIO_AUTH_TOKEN` |
    | Automation | `ZAPIER_WEBHOOK_URL`, `N8N_API_KEY` |
    | Self-Hosted | `COOLIFY_API_TOKEN`, `SEARXNG_URL`, `FIRECRAWL_API_KEY` |
    
    ## Standard Integrations
    
    ### Tier 1 (Every Product)
    
    **Slack Notifications:**
    
    ```typescript
    async function notifySlack(env: Env, message: string) {
      if (!env.SLACK_WEBHOOK_URL) return;
      await fetch(env.SLACK_WEBHOOK_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: message }),
      });
    }
    ```
    
    **Discord Webhooks** — same pattern with `embeds: [{ title, description, color: 0x00E5FF }]`
    
    ### Tier 2 (When Beneficial)
    
    - **Twilio** — SMS via REST API for phone-based features
    - **Zapier** — trigger workflows on key events (new donation, signup, deploy)
    - **Cal.com** — `<button data-cal-link="brian/30min" data-cal-config='{"theme":"dark"}'>Schedule a Call</button>`
    
    ## AI API Usage Strategy
    
    | Asset | API | Cost |
    |-------|-----|------|
    | Hero image | GPT Image 1.5 | ~$0.04 |
    | Logo | Ideogram v3 | ~$0.03 |
    | OG images | GPT Image 1.5 | ~$0.04/page |
    | Hero video (4s) | Sora 2 | ~$0.10 |
    | Alt text, translations, meta, blog, embeddings | Workers AI | $0 (free) |
    | Keyword research | Google Autocomplete | $0 |
    
    ## projectsites.dev Attribution
    
    ```html
    <footer><p>Built with <a href="https://projectsites.dev">projectsites.dev</a></p></footer>
    ```
    
    - Every site shipped IS the marketing
    - Quality = best ad
    
    ## Missing Key Prompt
    
    Never block on missing key. Always have fallback. Be casual:
    
    ```
    Hey — I need a [SERVICE] key. Options:
    1. [SERVICE] — [free tier] — [URL]
    2. Skip and use [fallback]
    (I'll store so you only enter once)
    ```
    
    ## Ownership
    
    - **Owns** — MCP discovery/connection, secrets discovery, cloud API patterns, AI API strategy, notification patterns, automation hooks, projectsites.dev branding
    - **Never owns** — specific implementations (→individual skills), donations + SMB payments (→`13/square-payments`), SaaS subscriptions + enterprise billing (→`13/stripe-billing`), email (→`09/email-templates`), deployment (→`08-deploy`)
    
  • multi-tenant-subdomain-provisioning.md 11.3 KB
    ---
    name: "Multi-Tenant Subdomain Provisioning"
    description: "Custom-domain provisioning on Cloudflare Workers for multi-tenant SaaS: ACME via CF SSL for SaaS, Workers Custom Domains API, dispatch namespaces for tenant isolation, wildcard vs apex cert, DNS verification flow, billing per domain."
    updated: "2026-06-18"
    always-load: false
    ---
    
    # Multi-Tenant Subdomain Provisioning
    
    Two modes: managed subdomains (`tenant.app.megabyte.space`) and tenant-owned custom domains (`dashboard.acme.com`). Managed is free and instant. Custom domains need ACME + DNS verification and cost $0.10/month per hostname on CF SSL for SaaS.
    
    ## Architecture Decision
    
    | Mode | When | Cost | Complexity |
    |------|------|------|------------|
    | Managed subdomain (`*.app.megabyte.space`) | Default for all tenants | Free | Low — wildcard cert, no DNS work |
    | Custom apex (`acme.com`) | Enterprise, custom branding | $0.10/hostname/month | High — ACME, DNS TXT verify |
    | Custom subdomain (`app.acme.com`) | Mid-market | $0.10/hostname/month | Medium — CNAME to CF |
    
    Always provision managed subdomain first. Custom domain is an upgrade.
    
    ## Managed Subdomain (Wildcard — Zero Work)
    
    One wildcard cert `*.app.megabyte.space` via CF dashboard. Workers route on `hostname`:
    
    ```typescript
    // wrangler.toml
    [[routes]]
    pattern = "*.app.megabyte.space/*"
    zone_name = "megabyte.space"
    
    // worker/tenant-router.ts
    export default {
      async fetch(req: Request, env: Env): Promise<Response> {
        const host = new URL(req.url).hostname           // tenant-slug.app.megabyte.space
        const slug = host.split('.')[0]
        const tenant = await getTenantBySlug(env, slug)
        if (!tenant) return new Response('Not found', { status: 404 })
        return handleTenantRequest(req, env, tenant)
      }
    }
    
    async function getTenantBySlug(env: Env, slug: string) {
      return env.DB.prepare('SELECT * FROM tenants WHERE slug = ? LIMIT 1')
        .bind(slug).first<Tenant>()
    }
    ```
    
    No cert provisioning, no DNS API — just a D1 row. Provision time: <50ms.
    
    ## Custom Domain: CF SSL for SaaS
    
    CF SSL for SaaS provisions TLS for tenant-owned hostnames served from YOUR zone. Docs: `developers.cloudflare.com/ssl/ssl-for-saas`.
    
    ### Prerequisites
    
    1. Your apex zone (`app.megabyte.space`) must use CF nameservers.
    2. Enable SSL for SaaS: CF Dashboard → SSL/TLS → Custom Hostnames → enable.
    3. Fallback origin: your Worker's route (not a plain IP).
    
    ### Provisioning Flow (CF API)
    
    ```typescript
    // worker/custom-domain-provisioning.ts
    const CF_API = 'https://api.cloudflare.com/client/v4'
    const headers = {
      'Authorization': `Bearer ${env.CF_API_TOKEN}`,
      'Content-Type': 'application/json',
    }
    
    export interface CustomHostnameProvision {
      tenantId: string
      hostname: string          // e.g. "dashboard.acme.com"
      verificationMethod: 'txt' | 'http' | 'email'
    }
    
    export async function provisionCustomHostname(
      env: Env,
      { tenantId, hostname, verificationMethod }: CustomHostnameProvision
    ): Promise<ProvisionResult> {
      // 1. Create custom hostname on CF
      const res = await fetch(
        `${CF_API}/zones/${env.CF_ZONE_ID}/custom_hostnames`,
        {
          method: 'POST',
          headers,
          body: JSON.stringify({
            hostname,
            ssl: {
              method: 'http',   // ACME HTTP-01 — no DNS access needed on tenant side
              type: 'dv',
              settings: {
                min_tls_version: '1.2',
                http2: 'on',
              },
            },
            custom_origin_server: env.WORKER_ORIGIN,  // your Workers route
          }),
        }
      )
      if (!res.ok) throw new Error(`CF API error: ${res.status} ${await res.text()}`)
      const { result } = await res.json<{ result: CFCustomHostname }>()
    
      // 2. Persist to D1 (pending_verification)
      await env.DB.prepare(`
        INSERT INTO tenant_custom_domains
          (tenant_id, hostname, cf_custom_hostname_id, status, verification_records, created_at)
        VALUES (?, ?, ?, 'pending_verification', ?, datetime('now'))
      `).bind(
        tenantId, hostname, result.id,
        JSON.stringify(result.ownership_verification ?? result.ssl.validation_records)
      ).run()
    
      return {
        cfId: result.id,
        status: result.status,
        verificationRecords: result.ssl.validation_records,
        ownershipVerification: result.ownership_verification,
      }
    }
    ```
    
    ### DNS Verification Flow
    
    ACME HTTP-01 is simplest: tenant just needs to CNAME their hostname to your fallback origin. Tenant never touches TXT records.
    
    ```
    tenant's DNS:  CNAME  dashboard.acme.com → fallback.app.megabyte.space
    your CF zone:  A      fallback.app.megabyte.space → Workers route
    ```
    
    For apex domains (`acme.com`) where CNAME is illegal, use ANAME/ALIAS or CF nameserver delegation:
    
    ```typescript
    // Return verification instructions based on hostname type
    function dnsInstructions(hostname: string): DnsInstructions {
      const isApex = hostname.split('.').length === 2
      if (isApex) {
        return {
          type: 'NS',
          note: 'Apex domains require NS delegation or ANAME support.',
          records: [
            { type: 'ANAME', name: '@', value: 'fallback.app.megabyte.space' },
          ],
          alternative: 'Use a subdomain (e.g., app.acme.com) for simpler CNAME setup.',
        }
      }
      return {
        type: 'CNAME',
        records: [{ type: 'CNAME', name: hostname.split('.')[0], value: 'fallback.app.megabyte.space' }],
      }
    }
    ```
    
    ### Status Polling (Workers Cron)
    
    CF takes 1–15 min to issue a DV cert. Poll and update D1:
    
    ```typescript
    // Cron: runs every 5 minutes
    export async function pollPendingDomains(env: Env) {
      const pending = await env.DB.prepare(`
        SELECT * FROM tenant_custom_domains
        WHERE status IN ('pending_verification', 'pending_issuance')
        AND created_at > datetime('now', '-7 days')
      `).all<TenantCustomDomain>()
    
      await Promise.all(pending.results.map(async (row) => {
        const res = await fetch(
          `${CF_API}/zones/${env.CF_ZONE_ID}/custom_hostnames/${row.cf_custom_hostname_id}`,
          { headers }
        )
        const { result } = await res.json<{ result: CFCustomHostname }>()
    
        if (result.status === 'active') {
          await env.DB.prepare(
            `UPDATE tenant_custom_domains SET status = 'active', activated_at = datetime('now')
             WHERE id = ?`
          ).bind(row.id).run()
          await notifyTenantDomainActive(env, row.tenant_id, row.hostname)
        } else if (result.ssl.status === 'validation_timed_out') {
          await env.DB.prepare(
            `UPDATE tenant_custom_domains SET status = 'failed', error = ? WHERE id = ?`
          ).bind('SSL validation timed out — check DNS records', row.id).run()
        }
      }))
    }
    ```
    
    ## Workers Dispatch Namespaces (Tenant Isolation at Code Level)
    
    For tenants needing isolated Worker code (plugins, custom logic), use Workers for Platforms dispatch namespaces.
    
    ```bash
    # One-time setup
    wrangler dispatch-namespace create saas-tenants
    
    # Deploy a tenant's custom script
    wrangler dispatch-namespace put saas-tenants/tenant-${tenantId} --script ./tenant-bundle.js
    ```
    
    ```typescript
    // wrangler.toml
    [[dispatch_namespaces]]
    binding = "TENANT_DISPATCHER"
    namespace = "saas-tenants"
    
    // worker/index.ts — route to tenant's custom Worker
    export default {
      async fetch(req: Request, env: Env): Promise<Response> {
        const tenant = await resolveTenant(req, env)
        if (tenant.has_custom_worker) {
          const tenantWorker = env.TENANT_DISPATCHER.get(
            `tenant-${tenant.id}`,
            {},
            { outbound: { worker: env.OUTBOUND_WORKER, params: { tenantId: tenant.id } } }
          )
          return tenantWorker.fetch(req)
        }
        return handleDefaultTenantRequest(req, env, tenant)
      }
    }
    ```
    
    Dispatch namespace billing: $0.02/million requests above free tier. Only use for true plugin-extensible SaaS.
    
    ## D1 Schema
    
    ```sql
    CREATE TABLE IF NOT EXISTS tenants (
      id          TEXT PRIMARY KEY,
      slug        TEXT UNIQUE NOT NULL,   -- managed subdomain handle
      name        TEXT NOT NULL,
      plan        TEXT NOT NULL DEFAULT 'free',
      created_at  TEXT NOT NULL DEFAULT (datetime('now'))
    );
    
    CREATE TABLE IF NOT EXISTS tenant_custom_domains (
      id                    INTEGER PRIMARY KEY AUTOINCREMENT,
      tenant_id             TEXT NOT NULL REFERENCES tenants(id),
      hostname              TEXT UNIQUE NOT NULL,
      cf_custom_hostname_id TEXT UNIQUE NOT NULL,
      status                TEXT NOT NULL DEFAULT 'pending_verification',
      -- pending_verification | pending_issuance | active | failed | deleted
      verification_records  TEXT,   -- JSON array of DNS records to show tenant
      error                 TEXT,
      created_at            TEXT NOT NULL DEFAULT (datetime('now')),
      activated_at          TEXT
    );
    
    CREATE INDEX idx_tcd_tenant ON tenant_custom_domains(tenant_id);
    CREATE INDEX idx_tcd_status ON tenant_custom_domains(status);
    ```
    
    ## Routing in the Main Worker
    
    ```typescript
    // worker/tenant-router.ts — unified hostname → tenant resolver
    export async function resolveTenant(req: Request, env: Env): Promise<Tenant | null> {
      const host = new URL(req.url).hostname
    
      // 1. Managed subdomain: slug.app.megabyte.space
      if (host.endsWith('.app.megabyte.space')) {
        const slug = host.replace('.app.megabyte.space', '')
        return env.DB.prepare('SELECT * FROM tenants WHERE slug = ?').bind(slug).first<Tenant>()
      }
    
      // 2. Custom domain — look up active entry
      const row = await env.DB.prepare(
        `SELECT t.* FROM tenants t
         JOIN tenant_custom_domains d ON d.tenant_id = t.id
         WHERE d.hostname = ? AND d.status = 'active'`
      ).bind(host).first<Tenant>()
      return row ?? null
    }
    ```
    
    ## Billing Per Domain
    
    Track in D1, bill monthly via Stripe metered billing:
    
    ```typescript
    // Cron: 1st of month — count active custom domains, report to Stripe
    export async function billCustomDomains(env: Env) {
      const { results } = await env.DB.prepare(`
        SELECT tenant_id, COUNT(*) as domain_count
        FROM tenant_custom_domains WHERE status = 'active'
        GROUP BY tenant_id
      `).all<{ tenant_id: string; domain_count: number }>()
    
      for (const { tenant_id, domain_count } of results) {
        const tenant = await getTenantById(env, tenant_id)
        if (!tenant.stripe_subscription_id) continue
        // Report metered usage: $0.15/domain/month (CF cost $0.10 + $0.05 margin)
        await stripe.subscriptionItems.createUsageRecord(tenant.stripe_usage_item_id, {
          quantity: domain_count,
          action: 'set',
        })
      }
    }
    ```
    
    ## Security
    
    - Validate `hostname` input: must be valid FQDN, max 253 chars, no wildcard (`*`), no internal hostnames.
    - Rate limit provisioning API: max 5 custom domains per tenant on free, 50 on enterprise.
    - On tenant account deletion: `DELETE /zones/${CF_ZONE_ID}/custom_hostnames/${cfId}` for each domain before deleting D1 rows.
    - Log all CF API calls to R2 audit trail per `enterprise-multi-tenancy` pattern.
    
    ## Wrangler Commands Reference
    
    ```bash
    # List custom hostnames (verify provisioning)
    wrangler api /zones/${CF_ZONE_ID}/custom_hostnames | jq '.result[] | {hostname, status}'
    
    # Force SSL recheck
    wrangler api /zones/${CF_ZONE_ID}/custom_hostnames/${CF_HOSTNAME_ID} --method PATCH \
      --data '{"ssl":{"method":"http"}}'
    
    # Delete a custom hostname (on tenant churn)
    wrangler api /zones/${CF_ZONE_ID}/custom_hostnames/${CF_HOSTNAME_ID} --method DELETE
    ```
    
    ## See
    
    - `enterprise-multi-tenancy` — D1-per-tenant isolation, RBAC, audit logging
    - `cf-auto-provision` — Worker + D1 + KV auto-setup scripts
    - `cloudflare-lock-in-is-leverage` — why CF primitives over portability layers
    - `stripe-billing` — metered billing for custom domain count
    - `secret-auto-provisioning` — `CF_API_TOKEN` retrieval via `get-secret`
    
  • openapi-generation.md 5.1 KB
    ---
    name: "OpenAPI Generation"
    description: "Auto-generate OpenAPI specs from Hono routes using @hono/zod-openapi. Define routes with createRoute() + Zod schemas, serve Swagger UI via middleware, generate client SDKs with openapi-typescript-codegen. Versioned /api/v1/ prefix with spec at /doc endpoint."
    updated: "2026-04-23"
    ---
    
    # OpenAPI Generation
    
    ## Route Definition (createRoute)
    
    ```typescript
    // src/routes/users.ts
    import { createRoute, z, OpenAPIHono } from '@hono/zod-openapi';
    
    const userSchema = z.object({
      id: z.string().ulid(),
      email: z.string().email(),
      name: z.string().min(1).max(255),
      role: z.enum(['admin', 'member', 'viewer']),
      createdAt: z.string().datetime(),
    }).openapi('User');
    
    const errorSchema = z.object({
      error: z.string(),
      code: z.string().optional(),
      details: z.unknown().optional(),
    }).openapi('Error');
    
    const listUsersRoute = createRoute({
      method: 'get',
      path: '/api/v1/users',
      tags: ['Users'],
      summary: 'List all users',
      request: {
        query: z.object({
          limit: z.coerce.number().int().min(1).max(100).default(20),
          cursor: z.string().optional(),
        }),
      },
      responses: {
        200: {
          content: { 'application/json': { schema: z.object({ users: z.array(userSchema), nextCursor: z.string().nullable() }) } },
          description: 'Paginated user list',
        },
        401: { content: { 'application/json': { schema: errorSchema } }, description: 'Unauthorized' },
      },
    });
    
    const createUserRoute = createRoute({
      method: 'post',
      path: '/api/v1/users',
      tags: ['Users'],
      summary: 'Create a user',
      request: {
        body: { content: { 'application/json': { schema: z.object({ email: z.string().email(), name: z.string().min(1), role: z.enum(['admin', 'member', 'viewer']).default('member') }) } } },
      },
      responses: {
        201: { content: { 'application/json': { schema: userSchema } }, description: 'Created' },
        400: { content: { 'application/json': { schema: errorSchema } }, description: 'Validation error' },
        409: { content: { 'application/json': { schema: errorSchema } }, description: 'Email exists' },
      },
    });
    ```
    
    ## App Setup with OpenAPI + Swagger UI
    
    ```typescript
    // src/index.ts
    import { OpenAPIHono } from '@hono/zod-openapi';
    import { swaggerUI } from '@hono/swagger-ui';
    
    const app = new OpenAPIHono<{ Bindings: Env }>();
    
    // Register routes (handlers use c.req.valid for type-safe params)
    app.openapi(listUsersRoute, async (c) => {
      const { limit, cursor } = c.req.valid('query');
      const users = await db.select().from(usersTable).limit(limit);
      return c.json({ users, nextCursor: null }, 200);
    });
    
    app.openapi(createUserRoute, async (c) => {
      const body = c.req.valid('json');
      const user = await db.insert(usersTable).values({ id: ulid(), ...body, createdAt: new Date().toISOString() }).returning();
      return c.json(user[0], 201);
    });
    
    // OpenAPI JSON spec endpoint
    app.doc('/api/v1/doc', {
      openapi: '3.1.0',
      info: { title: 'My API', version: '1.0.0', description: 'Auto-generated from Hono + Zod schemas' },
      servers: [{ url: 'https://api.example.com' }],
    });
    
    // Swagger UI
    app.get('/api/v1/ui', swaggerUI({ url: '/api/v1/doc' }));
    
    export default app;
    ```
    
    ## Client SDK Generation
    
    ```bash
    # Generate TypeScript client from live OpenAPI spec
    npx openapi-typescript-codegen --input https://api.example.com/api/v1/doc --output src/client --client fetch
    
    # Or from local spec file (build-time)
    npx openapi-typescript-codegen --input openapi.json --output src/client --client fetch --name ApiClient
    ```
    
    - Generated client provides typed methods — `ApiClient.users.listUsers({ limit: 20 })`, `ApiClient.users.createUser({ email, name })`
    - RPC via `hc<AppType>` still preferred for same-repo consumers
    - Generated SDK for external/third-party consumers
    
    ## Versioning Strategy
    
    ```typescript
    // Version prefix on all route groups
    const v1 = new OpenAPIHono<{ Bindings: Env }>();
    v1.openapi(listUsersRoute, handler);
    v1.openapi(createUserRoute, handler);
    
    const app = new OpenAPIHono<{ Bindings: Env }>();
    app.route('/api/v1', v1);
    // Future: app.route('/api/v2', v2);
    
    // Each version gets its own doc endpoint
    v1.doc('/doc', { openapi: '3.1.0', info: { title: 'My API', version: '1.0.0' } });
    ```
    
    ## Security Definitions
    
    ```typescript
    app.doc('/api/v1/doc', {
      openapi: '3.1.0',
      info: { title: 'My API', version: '1.0.0' },
      security: [{ bearerAuth: [] }],
      components: {
        securitySchemes: {
          bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'Clerk session token' },
        },
      },
    });
    
    // Per-route security override (public endpoint)
    const publicRoute = createRoute({
      method: 'get',
      path: '/api/v1/health',
      security: [], // No auth required
      responses: { 200: { content: { 'application/json': { schema: z.object({ status: z.literal('ok'), version: z.string(), timestamp: z.string() }) } }, description: 'Health check' } },
    });
    ```
    
    ## Dependencies
    
    ```bash
    pnpm add @hono/zod-openapi @hono/swagger-ui
    pnpm add -D openapi-typescript-codegen
    ```
    
    ## Pattern
    
    - Zod schemas — single source of truth
    - `createRoute` defines request/response contracts
    - OpenAPI spec auto-generated
    - Swagger UI for manual testing
    - SDK codegen for external consumers
    - Never manually write OpenAPI YAML
    
  • shared-api-pool.md 6.2 KB
    ---
    name: "Shared API Pool"
    description: "185 API keys across 27 categories in chezmoi+age encrypted store. Auto-discovery via get-secret. Every project auto-integrates with available services. Full catalog in ~/.claude/research/api-library.md (150+ APIs with free tiers, env vars, MCP availability, and integration examples)."
    updated: "2026-04-23"
    ---
    
    # Shared API Key Pool
    
    ## Rule
    
    ALL Emdash projects share a common pool of API keys. When building any project, automatically integrate with every service that has a key available. Run `discover-secrets.sh` to see what's unlocked.
    
    ## Secret Discovery (3 Sources, Priority Order)
    
    ### 1. Chezmoi Encrypted Store (185 secrets) — PRIMARY
    
    ```bash
    get-secret SECRET_NAME          # Decrypt any secret by name
    discover-secrets.sh             # Full inventory
    discover-secrets.sh --check KEY # Check single key
    ```
    
    - **Location** — `~/.local/share/chezmoi/home/.chezmoitemplates/secrets/`
    - **Cross-machine sync** — `chezmoi apply` on any new machine → all 185 secrets available instantly
    
    ### 2. Shared .env.local (54 keys, runtime-loaded)
    
    Active project `.env.local` (check `$CLAUDE_ENV_FILE` when set)
    
    ### 3. Emdash Config
    
    `~/.config/emdash/` — `coolify-token`, `gcp-service-account.json`
    
    ## Available Keys by Category
    
    ### AI/ML (11 keys)
    
    - `OPENAI_API_KEY` — GPT Image 1.5/Whisper (DALL-E deprecated May 2026)
    - `ANTHROPIC_API_KEY` — Claude
    - `GEMINI_API_KEY` — Gemini
    - `DEEPSEEK_API_KEY` — DeepSeek V3/R1
    - `DEEPGRAM_API_KEY` — STT ($200 free)
    - `ELEVENLABS_API_KEY` — TTS/voice clone
    - `REPLICATE_API_TOKEN` — any ML model
    - `MISTRAL_API_KEY` — open-weight LLMs
    - `CEREBRAS_API_KEY` — fast inference
    - `BASETEN_API_KEY` — model hosting
    - `CARTESIA_API_KEY` — voice AI
    
    **Missing:** `GROQ_API_KEY` (fastest, free 30rpm) | `TOGETHER_API_KEY` ($5 free) | `FIREWORKS_AI_API_KEY` ($1 free) | `COHERE_API_KEY` (free embeddings) | `STABILITY_API_KEY` (25 free/day) | `PERPLEXITY_API_KEY` | `FAL_AI_API_KEY` | `ASSEMBLYAI_API_KEY` ($50 free) | `LUMA_API_KEY` | `SUNO_API_KEY`
    
    ### Communication (12 keys)
    
    - `SLACK_WEBHOOK_URL`, `SLACK_API_TOKEN`, `SLACK_BOT_USER_OAUTH_TOKEN`, `SLACK_CLIENT_ID/SECRET`
    - `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID/SECRET`
    - `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`
    - `TELEGRAM_BOT_TOKEN` + `TELEGRAM_BOT_NAME` + `TELEGRAM_RECIPIENT_ID`
    
    **Missing:** `VONAGE_API_KEY`
    
    ### Cloud/Infra (15+ keys)
    
    - `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_API_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_EMAIL`
    - `CLOUDFLARE_R2_*` (6 keys)
    - `CLOUDFLARE_TEAMS_*` (2 keys)
    - `CLOUDFLARE_SSH_API_TOKEN`, `CLOUDFLARE_ORIGIN_CA_KEY`
    - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
    - `DIGITALOCEAN_ACCESS_TOKEN`
    
    ### Media (6 keys)
    
    - `PEXELS_API_KEY`, `PIXABAY_API_KEY`
    - `IDEOGRAM_API_KEY` — text-in-image
    - `RUNWAY_API_KEY` — video gen
    - `REPLICATE_API_TOKEN` — any model
    - `CLOUDINARY_API_KEY` + `SECRET` + `CLOUD_NAME` — image CDN
    
    ### Social (15+ keys)
    
    - `POSTIZ_API_KEY` — social scheduling
    - `TWITTER_API_KEY` + `SECRET` + `ACCESS_TOKEN` + `BEARER_TOKEN` + `OAUTH_*` (7 keys)
    - `FACEBOOK_OAUTH_ID/SECRET`
    - `REDDIT_APP_ID` + `SECRET` + `USERNAME` + `PASSWORD`
    - `PINTEREST_OAUTH_ID/SECRET`
    - `YOUTUBE_OAUTH_ID/SECRET`
    - `DEVTO_API_KEY`
    
    ### Payments
    
    - STRIPE via project `.env` — `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`
    
    **Missing:** `LEMONSQUEEZY_API_KEY` (handles tax/VAT automatically)
    
    ### Email (2 keys)
    
    - `RESEND_API_KEY` — modern, React Email
    - `SENDGRID_API_KEY` — legacy (free tier removed May 2025)
    
    **Missing:** `POSTMARK_SERVER_TOKEN` (highest deliverability) | `LOOPS_API_KEY` (SaaS marketing) | `PLUNK_SECRET_KEY` (open-source)
    
    ### Search (3 keys)
    
    - `GOOGLE_SEARCH_API_KEY` + `GOOGLE_SEARCH_ID`
    - `SERP_API_KEY`
    - `TAVILY_API_KEY` — AI search
    
    **Missing:** `ALGOLIA_APP_ID` + `API_KEY` | `TYPESENSE_API_KEY` | `BROWSERBASE_API_KEY` (for Stagehand)
    
    ### Maps/Geo (3 keys)
    
    - `GOOGLE_MAPS_API_KEY`
    - `MAPBOX_ACCESS_TOKEN`
    - `FOURSQUARE_API_KEY`
    
    **Missing:** `LOCATIONIQ_TOKEN` (free 10K/day) | `OPENWEATHERMAP_API_KEY` (in chezmoi)
    
    ### Dev (7 keys)
    
    - `GITHUB_TOKEN`, `GITHUB_GIST_TOKEN`, `GITHUB_READ_TOKEN`
    - `GITLAB_TOKEN`, `GITLAB_READ_TOKEN`
    - `NPM_TOKEN`
    - `DOCKERHUB_TOKEN`
    
    ### Domain/DNS (2 keys)
    
    - `GODADDY_API_KEY` + `SECRET`
    - `WHOISXML_API_KEY`
    
    **Missing:** `NAMECHEAP_API_KEY` | `DUB_API_KEY` (link shortening)
    
    ### Self-Hosted (8 keys)
    
    - `OMI_DEV_KEY` + `OMI_MCP_KEY` — Omi wearable
    - `HASS_TOKEN` — Home Assistant
    - `SUPERMEMORY_TOKEN`
    - `N8N_API_KEY`, `PLANE_API_KEY`, `NOTION_TOKEN`
    - `WP_API_PASSWORD` + `USERNAME`
    - Plus Coolify token at `~/.config/emdash/coolify-token`
    
    ### Infrastructure/VPN (10+ keys)
    
    - `TAILSCALE_AUTH_KEY`, `NGROK_AUTH_TOKEN`
    - `NORDVPN_*`, `PROTONVPN_*`
    - `PROXMOX_ROOT_PASSWORD`
    - `RESTIC_PASSWORD` (backups)
    - `HISHTORY_USER_SECRET`, `WAKATIME_API_KEY`
    
    ## MCP Servers with Keys (26 servers installed)
    
    Cloudflare | GitHub | Playwright | Stripe | Slack | Airtable | Figma | Google Cal/Drive/Gmail | IFTTT | Notion | Plane | Coolify | WordPress | PostHog | Sentry | Firecrawl | DeepSeek | Sequential Thinking | Supermemory | Computer Use | Context7 | Neon | Snyk | Semgrep | Upstash | Twilio | Replicate | ElevenLabs | Meilisearch | Accessibility Scanner | Idea Reality
    
    ## Self-Hosted Services (Proxmox/Coolify, 70+ containers)
    
    - Services follow pattern `{service}.megabyte.space` behind CF Tunnels + Authentik SSO
    - 70+ containers managed via Coolify; all URLs discoverable via Coolify API or `~/.config/emdash/`
    
    ## Integration Protocol
    
    For EVERY new project:
    
    1. Scan key pool via `discover-secrets.sh`
    2. Create PostHog + Sentry project instances
    3. Inject keys as CF Worker secrets via `wrangler secret put`
    4. Configure client-side snippets
    5. Set up server-side reporting
    
    **MANDATORY** — PostHog analytics + Sentry errors on every project.
    
    ## Full API Catalog
    
    - 150+ APIs with free tiers, env vars, MCP availability, and code examples — `~/.claude/research/api-library.md`
    - Sideload when choosing which APIs to integrate: `Read ~/.claude/research/api-library.md`
    
    ## Key Discovery Order
    
    1. Check `.env.local` in current project
    2. Check shared pool at rare-chefs `.env.local`
    3. Run `get-secret KEY` (chezmoi+age)
    4. Check `~/.config/emdash/` tokens
    5. Not found → sign up (most have free tiers) → `chezmoi add --encrypt`
    
  • SKILL.md 5.6 KB
    ---
    name: "architecture-and-stack"
    description: "Cloudflare-first platform selection. Decision trees for Workers, D1, R2, KV, DO, Queues, Vectorize, Containers, Sandboxes, Flagship, Agent Memory, Workflows v2. Default stack, override conditions, auth, data patterns, reliability."
    metadata:
      version: "2.1.0"
      updated: "2026-05-03"
      effort: "high"
      model: "opus"
    license: "Rutgers"
    compatibility:
      claude-code: ">=2.0.0"
      agentskills: ">=1.0.0"
    submodules:
      - ai-technology-integration.md
      - api-design-and-documentation.md
      - auth-and-session-management.md
      - background-jobs-and-workflows.md
      - cf-2026-updates.md
      - cf-auto-provision.md
      - coolify-docker-proxmox.md
      - drizzle-orm-and-migrations.md
      - enterprise-multi-tenancy.md
      - heartbeat-polling.md
      - mcp-and-cloud-integrations.md
      - openapi-generation.md
      - shared-api-pool.md
    priority: 2
    pack: "backend"
    stage: stable
    triggers:
      - "architecture"
      - "stack"
      - "cloudflare"
      - "d1"
      - "workers"
    paths:
      - "concern:cloudflare-workers"
    ---
    
    # 05 — Architecture and Stack
    
    Default stack: `_kernel/standards.md#stack`. Override conditions below.
    
    ## Cloudflare-first decision tree
    
    **Compute**: Workers (default, every HTTP/cron/queue) · Pages (static-only marketing, rare) · Containers (non-JS runtimes: Playwright headful, ffmpeg, Python ML, build orchestration) · Sandbox SDK (generated/risky code before live promotion)
    
    **State**: D1 (default relational, ≤10GB/db, Sessions API read-replicas, Time Travel 30-day PIT) · KV (eventually-consistent, cache/sessions/feature-flags) · R2 (object storage, lifecycle Standard→IA after 30d) · Durable Objects (coordination + strongly-consistent SQLite storage since Apr 2025, chat rooms/builder sessions/rate-limiting) · Hyperdrive (front external Postgres/MySQL) · Vectorize (semantic search/RAG, 5M dim/index, topK 100, 10 metadata indexes)
    
    **Async**: Queues (best-effort, 5000 msg/sec, R2 event notifications) · Workflows v2 (deterministic, 50K concurrent, 300 creates/sec, 2M queued/workflow, `step.do` + `step.sleep` + `step.waitForEvent`) · Inngest (event-driven, better DX/observability)
    
    **AI**: Workers AI (Llama 3.3 70B FP8 free, Llama 3.1 8B FP8, Llama 4 Scout 17B vision) · AI Gateway (caching + rate-limit + fallback + logging for every LLM call) · Vectorize (embeddings + ANN search)
    
    ## Override conditions (when CF isn't enough)
    
    | Need | Fallback | Adapter |
    |---|---|---|
    | Advanced SQL (RLS, OLAP, partial indexes) | Neon Postgres via Hyperdrive | `SqlPort` |
    | Redis primitives at scale (sorted sets, streams) | Upstash Redis | `KvPort` |
    | Sub-millisecond global state | Upstash QStash | `QueuePort` |
    | Specific provider (OpenAI assistants, Anthropic batch) | Direct API via AI Gateway | `AiPort` |
    | Vector + SQL co-located | Neon pgvector | `VectorPort` |
    
    Adapters live in `libs/core/ports/`. Product code imports port, never vendor SDK directly. See `rules/cloudflare-hostable-supervisor.md`.
    
    ## Auth (default Clerk M2M JWT)
    
    - **Clerk** — M2M JWT (free, networkless verification), passkeys, OAuth, magic links; **Better Auth** when Clerk pricing doesn't fit (rare)
    - Hash API keys at rest. Audit log every sensitive action.
    - Tenant isolation: every table carries `org_id`, every query filters by it (404 on mismatch, never 403)
    
    ## Data patterns
    
    **D1**
    
    ```toml
    [[d1_databases]]
    binding = "DB"
    database_name = "myapp"
    ```
    
    - `wrangler types` against `compatibility_date` + bindings (preferred over hand-maintained Env interface)
    - Drizzle v1 RQBv2 + Zod for query + validation; batch via `db.batch([...])` (no transactions in D1)
    - Sessions API: `db.withSession(bookmark)` · Time Travel: `wrangler d1 time-travel restore`
    
    **R2**: per-extension content-type on upload · lifecycle Standard→IA after 30d · event notifications → Queues at 5000 msg/sec for thumbnailing/indexing · versioning for asset rollback
    
    **Durable Objects**: one DO per stateful entity · SQLite-backed, 10GB per DO · direct stub `env.MY_DO.getByName(name)` · alarm misfires → idempotent handler
    
    ## Reliability
    
    - Workers CPU 10ms free / 50ms paid default (configurable 5min); wall time 30s paid
    - `ctx.waitUntil()` for async post-response work; `ctx.passThroughOnException()` for graceful degradation
    - WebSocket + JSRPC payload up to 32 MiB
    
    ## Cost discipline
    
    - Workers free tier: 100k req/day; Workers Paid: $5/mo (10M req + 30M CPU-ms) + $0.30/M extra req + $0.02/M extra CPU-ms
    - D1 on Workers Paid: 5GB + 25B rows-read + 50M rows-written/mo; then $0.75/GB-mo + $0.001/M rows-read + $1/M rows-written; no egress; read replication included (verified 2026-06-09)
    - R2: 10GB free, $0.015/GB-mo, $0/egress · Workers AI Llama 3.3 70B FP8 FREE · AI Gateway free
    - Solo SaaS <$100k/mo MRR stays 10-100× cheaper than AWS-equivalent on CF
    
    ## Default config (`wrangler.jsonc`)
    
    ```jsonc
    {
      "name": "myapp",
      "main": "src/worker/index.ts",
      "compatibility_date": "2026-04-15",
      "compatibility_flags": ["nodejs_compat"],
      "observability": { "enabled": true },
      "secrets_required": ["CLERK_SECRET_KEY", "RESEND_API_KEY"],
      "d1_databases": [{ "binding": "DB", "database_name": "myapp" }],
      "kv_namespaces": [{ "binding": "CACHE", "id": "..." }],
      "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "myapp-assets" }],
      "ai": { "binding": "AI" }
    }
    ```
    
    ## Decision template (use for every architecture call)
    
    1. Can CF primitive do this? → Use it.
    2. Does this need adapter for portability? → Adapter only if real business case.
    3. Cost projection at 10× current scale → Still affordable?
    4. Failure mode → Graceful degradation defined?
    5. Migration path → If we have to leave CF, what does it cost?
    
    ## See submodules: cloudflare-primitives.md, data-patterns.md, reliability.md, auth-patterns.md.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related