Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-06-16-cloudflare-workers-deployment.md
T

42 KiB

Cloudflare Workers Deployment Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the app deployable to Cloudflare Workers with OpenNext, D1-backed data, private R2 exports, explicit local/online separation, and API-key protection.

Architecture: Keep the existing local Node path intact while adding a Cloudflare path selected through a small runtime boundary. Route handlers use async repository and export-store interfaces; local implementation uses the existing SQLite/filesystem behavior, while Cloudflare implementation uses getCloudflareContext().env.DB and env.EXPORT_BUCKET.

Tech Stack: Next.js 16, TypeScript, OpenNext Cloudflare adapter, Wrangler, Cloudflare D1, Cloudflare R2, Vitest.


File Structure

  • Modify package.json: add Cloudflare dependencies and explicit scripts.
  • Modify .gitignore: ignore .open-next, .wrangler, .dev.vars, and generated Cloudflare type files if needed.
  • Modify next.config.ts: initialize OpenNext dev integration.
  • Create open-next.config.ts: OpenNext Cloudflare adapter config.
  • Create wrangler.jsonc: Worker, assets, D1, R2, env, and observability config.
  • Create migrations/0001_initial_schema.sql: D1 initial schema copied from the current SQLite schema.
  • Create src/lib/runtime/cloudflare.ts: typed access to Cloudflare bindings.
  • Create src/lib/api/auth.ts: API key guard shared by route handlers.
  • Create src/lib/db/repository.ts: async repository interface and runtime selector.
  • Create src/lib/db/sqlite-repository.ts: async wrapper around existing SQLite functions for local runtime.
  • Create src/lib/db/d1-repository.ts: D1 implementation.
  • Create src/lib/workflow/export-store.ts: local and R2 export-store implementations.
  • Modify API routes under src/app/api/jobs/**/route.ts: use auth, async repository, and export store.
  • Modify tests under src/app/api/__tests__, src/lib/db/__tests__, and src/lib/workflow/__tests__.
  • Modify README.md and .env.example: document local vs staging vs production deployment.

Task 1: Add Cloudflare Build Configuration

Files:

  • Modify: package.json

  • Modify: .gitignore

  • Modify: next.config.ts

  • Create: open-next.config.ts

  • Create: wrangler.jsonc

  • Create: public/_headers

  • Create: .dev.vars.example

  • Step 1: Install dependencies

Run:

npm install @opennextjs/cloudflare@latest
npm install --save-dev wrangler@latest @cloudflare/workers-types@latest

Expected: package.json and package-lock.json include the new packages.

  • Step 2: Update package.json scripts

Edit the scripts block to include these entries while preserving existing scripts:

{
  "test": "vitest run",
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint",
  "test:watch": "vitest",
  "build:worker": "opennextjs-cloudflare build",
  "preview:worker": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
  "deploy:worker:staging": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env staging",
  "deploy:worker:production": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --env production",
  "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
  "d1:migrate:local": "wrangler d1 migrations apply geo-agent-article-optimizer-local --local",
  "d1:migrate:staging": "wrangler d1 migrations apply geo-agent-article-optimizer-staging --env staging --remote",
  "d1:migrate:production": "wrangler d1 migrations apply geo-agent-article-optimizer-production --env production --remote"
}
  • Step 3: Add ignored generated/local files

Append these entries to .gitignore:

.open-next
.wrangler
.dev.vars
cloudflare-env.d.ts
  • Step 4: Add OpenNext config

Create open-next.config.ts:

import { defineCloudflareConfig } from "@opennextjs/cloudflare";

export default defineCloudflareConfig({});
  • Step 5: Initialize OpenNext dev support in next.config.ts

Replace next.config.ts with:

import type { NextConfig } from "next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";

const nextConfig: NextConfig = {};

export default nextConfig;

initOpenNextCloudflareForDev();
  • Step 6: Add wrangler.jsonc

Create wrangler.jsonc with local bindings only. Add staging and production entries after creating the real Cloudflare resources in Task 9, so no fake remote database IDs are committed.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "main": ".open-next/worker.js",
  "name": "geo-agent-article-optimizer",
  "compatibility_date": "2026-06-16",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  },
  "observability": {
    "enabled": true,
    "head_sampling_rate": 0.1
  },
  "services": [
    {
      "binding": "WORKER_SELF_REFERENCE",
      "service": "geo-agent-article-optimizer"
    }
  ],
  "vars": {
    "APP_RUNTIME": "cloudflare",
    "LLM_PROVIDER": "deepseek",
    "DEEPSEEK_BASE_URL": "https://api.deepseek.com",
    "DEEPSEEK_MODEL": "deepseek-v4-pro",
    "DEEPSEEK_THINKING": "disabled",
    "OPENAI_MODEL": "gpt-4.1-mini"
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "geo-agent-article-optimizer-local",
      "database_id": "00000000-0000-0000-0000-000000000001",
      "migrations_dir": "migrations"
    }
  ],
  "r2_buckets": [
    {
      "binding": "EXPORT_BUCKET",
      "bucket_name": "geo-agent-article-optimizer-local"
    }
  ],
  "env": {}
}
  • Step 7: Add static asset caching headers

Create public/_headers:

/_next/static/*
  Cache-Control: public,max-age=31536000,immutable
  • Step 8: Add local Worker env example

Create .dev.vars.example:

NEXTJS_ENV=development
API_ACCESS_KEY=local-worker-dev-key
DEEPSEEK_API_KEY=
OPENAI_API_KEY=
  • Step 9: Generate Cloudflare binding types

Run:

npm run cf-typegen

Expected: command exits 0. The generated cloudflare-env.d.ts remains ignored.

Task 2: Add D1 Migration

Files:

  • Create: migrations/0001_initial_schema.sql

  • Step 1: Create initial migration

Create migrations/0001_initial_schema.sql:

CREATE TABLE IF NOT EXISTS brand_templates (
  id TEXT PRIMARY KEY,
  brand_name TEXT NOT NULL,
  company_full_name TEXT NOT NULL,
  company_short_names TEXT NOT NULL,
  product_names TEXT NOT NULL,
  target_industries TEXT NOT NULL,
  target_audience TEXT NOT NULL,
  verified_claims TEXT NOT NULL,
  forbidden_claims TEXT NOT NULL,
  tone_rules TEXT NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS article_jobs (
  id TEXT PRIMARY KEY,
  brand_template_id TEXT,
  source_title TEXT NOT NULL,
  source_body TEXT NOT NULL,
  image_inputs TEXT NOT NULL,
  publish_platform TEXT NOT NULL,
  user_instructions TEXT NOT NULL,
  status TEXT NOT NULL,
  export_paths TEXT NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (brand_template_id) REFERENCES brand_templates(id)
);

CREATE TABLE IF NOT EXISTS fact_cards (
  job_id TEXT PRIMARY KEY,
  source TEXT NOT NULL,
  fact_card TEXT NOT NULL,
  confirmed_by_user INTEGER NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (job_id) REFERENCES article_jobs(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS optimized_articles (
  job_id TEXT NOT NULL,
  revision INTEGER NOT NULL,
  article TEXT NOT NULL,
  created_at TEXT NOT NULL,
  PRIMARY KEY (job_id, revision),
  FOREIGN KEY (job_id) REFERENCES article_jobs(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS qa_reports (
  job_id TEXT NOT NULL,
  revision INTEGER NOT NULL,
  report TEXT NOT NULL,
  created_at TEXT NOT NULL,
  PRIMARY KEY (job_id, revision),
  FOREIGN KEY (job_id, revision)
    REFERENCES optimized_articles(job_id, revision) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_article_jobs_updated_at
  ON article_jobs(updated_at);
  • Step 2: Apply migration locally

Run:

npm run d1:migrate:local

Expected: Wrangler applies 0001_initial_schema.sql to the local D1 database. Runtime code must not call CREATE TABLE, DROP TABLE, or table-clearing SQL for the Cloudflare path.

Task 3: Introduce API Key Guard

Files:

  • Create: src/lib/api/auth.ts

  • Test: src/lib/api/__tests__/auth.test.ts

  • Step 1: Write failing auth tests

Create src/lib/api/__tests__/auth.test.ts:

import { describe, expect, test } from "vitest";

import { requireApiAccess } from "../auth";

describe("requireApiAccess", () => {
  test("allows local test requests when auth is explicitly disabled", () => {
    const request = new Request("http://localhost/api/jobs");

    const result = requireApiAccess(request, {
      apiAccessKey: undefined,
      authDisabled: true,
    });

    expect(result.ok).toBe(true);
  });

  test("rejects requests when the configured key is missing", () => {
    const request = new Request("http://localhost/api/jobs");

    const result = requireApiAccess(request, {
      apiAccessKey: "secret",
      authDisabled: false,
    });

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.response.status).toBe(401);
    }
  });

  test("rejects requests with the wrong key", () => {
    const request = new Request("http://localhost/api/jobs", {
      headers: { "x-api-key": "wrong" },
    });

    const result = requireApiAccess(request, {
      apiAccessKey: "secret",
      authDisabled: false,
    });

    expect(result.ok).toBe(false);
  });

  test("allows requests with the correct key", () => {
    const request = new Request("http://localhost/api/jobs", {
      headers: { "x-api-key": "secret" },
    });

    const result = requireApiAccess(request, {
      apiAccessKey: "secret",
      authDisabled: false,
    });

    expect(result.ok).toBe(true);
  });
});
  • Step 2: Run auth tests and verify RED

Run:

npm test -- src/lib/api/__tests__/auth.test.ts

Expected: FAIL because src/lib/api/auth.ts does not exist.

  • Step 3: Implement auth guard

Create src/lib/api/auth.ts:

import { NextResponse } from "next/server";

interface ApiAccessOptions {
  apiAccessKey?: string;
  authDisabled?: boolean;
}

type ApiAccessResult =
  | { ok: true }
  | { ok: false; response: NextResponse<{ error: string }> };

export function requireApiAccess(
  request: Request,
  options: ApiAccessOptions = {
    apiAccessKey: process.env.API_ACCESS_KEY,
    authDisabled: process.env.API_AUTH_DISABLED === "true",
  },
): ApiAccessResult {
  if (options.authDisabled) {
    return { ok: true };
  }

  if (!options.apiAccessKey) {
    return {
      ok: false,
      response: NextResponse.json(
        { error: "API access key is not configured" },
        { status: 401 },
      ),
    };
  }

  const provided = request.headers.get("x-api-key");
  if (provided !== options.apiAccessKey) {
    return {
      ok: false,
      response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
    };
  }

  return { ok: true };
}
  • Step 4: Run auth tests and verify GREEN

Run:

npm test -- src/lib/api/__tests__/auth.test.ts

Expected: PASS.

Task 4: Add Repository Boundary And Local Adapter

Files:

  • Create: src/lib/db/repository.ts

  • Create: src/lib/db/sqlite-repository.ts

  • Modify: src/lib/db/repositories.ts

  • Test: src/lib/db/__tests__/repository.test.ts

  • Step 1: Write failing repository adapter test

Create src/lib/db/__tests__/repository.test.ts:

import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

import { afterEach, beforeEach, describe, expect, test } from "vitest";

import { createSqliteRepository } from "../sqlite-repository";

describe("createSqliteRepository", () => {
  let tempDir: string;
  let dbPath: string;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), "geo-repository-"));
    dbPath = join(tempDir, "app.db");
  });

  afterEach(() => {
    rmSync(tempDir, { recursive: true, force: true });
  });

  test("creates and reads an article job through the async repository interface", async () => {
    const repository = createSqliteRepository(dbPath);

    const job = await repository.createArticleJob({
      source_title: "Title",
      source_body: "Body",
      image_inputs: [],
      publish_platform: "official_site",
      user_instructions: "",
    });

    await expect(repository.getArticleJob(job.id)).resolves.toMatchObject({
      id: job.id,
      source_title: "Title",
      export_paths: {},
    });
  });
});
  • Step 2: Run repository adapter test and verify RED

Run:

npm test -- src/lib/db/__tests__/repository.test.ts

Expected: FAIL because sqlite-repository does not exist.

  • Step 3: Create async repository interface

Create src/lib/db/repository.ts:

import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type {
  ArticleJob,
  BrandTemplate,
  NewArticleJob,
  NewBrandTemplate,
} from "./repositories";

export interface AppRepository {
  createBrandTemplate(input: NewBrandTemplate): Promise<BrandTemplate>;
  listBrandTemplates(): Promise<BrandTemplate[]>;
  getBrandTemplate(id: string): Promise<BrandTemplate | null>;
  createArticleJob(input: NewArticleJob): Promise<ArticleJob>;
  getArticleJob(id: string): Promise<ArticleJob | null>;
  updateArticleJob(
    id: string,
    changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
  ): Promise<ArticleJob | null>;
  saveFactCard(jobId: string, factCard: ConfirmedFactCard): Promise<{ job_id: string } & ConfirmedFactCard>;
  getFactCard(jobId: string): Promise<({ job_id: string } & ConfirmedFactCard) | null>;
  saveOptimizedArticle(jobId: string, article: OptimizedArticle): Promise<OptimizedArticle>;
  getLatestOptimizedArticle(jobId: string): Promise<OptimizedArticle | null>;
  saveQaReport(jobId: string, revision: number, report: QaReport): Promise<QaReport>;
  getQaReport(jobId: string, revision: number): Promise<QaReport | null>;
}
  • Step 4: Create local SQLite adapter

Create src/lib/db/sqlite-repository.ts:

import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type { AppRepository } from "./repository";
import {
  createArticleJob,
  createBrandTemplate,
  getArticleJob,
  getBrandTemplate,
  getFactCard,
  getLatestOptimizedArticle,
  getQaReport,
  listBrandTemplates,
  saveFactCard,
  saveOptimizedArticle,
  saveQaReport,
  updateArticleJob,
  type ArticleJob,
  type NewArticleJob,
  type NewBrandTemplate,
} from "./repositories";

export function createSqliteRepository(dbPath?: string): AppRepository {
  return {
    createBrandTemplate(input: NewBrandTemplate) {
      return Promise.resolve(createBrandTemplate(dbPath, input));
    },
    listBrandTemplates() {
      return Promise.resolve(listBrandTemplates(dbPath));
    },
    getBrandTemplate(id: string) {
      return Promise.resolve(getBrandTemplate(dbPath, id));
    },
    createArticleJob(input: NewArticleJob) {
      return Promise.resolve(createArticleJob(dbPath, input));
    },
    getArticleJob(id: string) {
      return Promise.resolve(getArticleJob(dbPath, id));
    },
    updateArticleJob(
      id: string,
      changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
    ) {
      return Promise.resolve(updateArticleJob(dbPath, id, changes));
    },
    saveFactCard(jobId: string, factCard: ConfirmedFactCard) {
      return Promise.resolve(saveFactCard(dbPath, jobId, factCard));
    },
    getFactCard(jobId: string) {
      return Promise.resolve(getFactCard(dbPath, jobId));
    },
    saveOptimizedArticle(jobId: string, article: OptimizedArticle) {
      return Promise.resolve(saveOptimizedArticle(dbPath, jobId, article));
    },
    getLatestOptimizedArticle(jobId: string) {
      return Promise.resolve(getLatestOptimizedArticle(dbPath, jobId));
    },
    saveQaReport(jobId: string, revision: number, report: QaReport) {
      return Promise.resolve(saveQaReport(dbPath, jobId, revision, report));
    },
    getQaReport(jobId: string, revision: number) {
      return Promise.resolve(getQaReport(dbPath, jobId, revision));
    },
  };
}
  • Step 5: Run repository adapter test and verify GREEN

Run:

npm test -- src/lib/db/__tests__/repository.test.ts

Expected: PASS.

Task 5: Add D1 Repository

Files:

  • Create: src/lib/db/d1-repository.ts

  • Test: src/lib/db/__tests__/d1-repository.test.ts

  • Step 1: Write D1 repository tests with a fake D1 database

Create src/lib/db/__tests__/d1-repository.test.ts with a fake prepared-statement database that records SQL and returns controlled rows:

import { describe, expect, test, vi } from "vitest";

import { createD1Repository } from "../d1-repository";

describe("createD1Repository", () => {
  test("creates an article job using D1 prepare and bind", async () => {
    const run = vi.fn().mockResolvedValue({ success: true });
    const bind = vi.fn().mockReturnValue({ run });
    const prepare = vi.fn().mockReturnValue({ bind });
    const db = { prepare } as unknown as D1Database;

    const repository = createD1Repository(db);

    const job = await repository.createArticleJob({
      source_title: "Title",
      source_body: "Body",
      image_inputs: [],
      publish_platform: "official_site",
      user_instructions: "",
    });

    expect(job.id).toMatch(/^job_/);
    expect(prepare).toHaveBeenCalledWith(expect.stringContaining("insert into article_jobs"));
    expect(bind).toHaveBeenCalledWith(
      job.id,
      null,
      "Title",
      "Body",
      "[]",
      "official_site",
      "",
      "draft",
      "{}",
      job.created_at,
      job.updated_at,
    );
  });

  test("parses article job JSON fields returned from D1", async () => {
    const first = vi.fn().mockResolvedValue({
      id: "job_123",
      brand_template_id: null,
      source_title: "Title",
      source_body: "Body",
      image_inputs: "[]",
      publish_platform: "official_site",
      user_instructions: "",
      status: "draft",
      export_paths: "{}",
      created_at: "2026-06-16T00:00:00.000Z",
      updated_at: "2026-06-16T00:00:00.000Z",
    });
    const bind = vi.fn().mockReturnValue({ first });
    const prepare = vi.fn().mockReturnValue({ bind });
    const db = { prepare } as unknown as D1Database;

    const repository = createD1Repository(db);

    await expect(repository.getArticleJob("job_123")).resolves.toMatchObject({
      id: "job_123",
      image_inputs: [],
      export_paths: {},
    });
  });
});
  • Step 2: Run D1 repository tests and verify RED

Run:

npm test -- src/lib/db/__tests__/d1-repository.test.ts

Expected: FAIL because d1-repository does not exist.

  • Step 3: Implement D1 repository

Create src/lib/db/d1-repository.ts with the full async D1 implementation below. Use prepare(...).bind(...).run(), first<T>(), and all<T>(). Do not include schema creation or destructive SQL in this file.

The implementation must include these helpers:

function nowIso() {
  return new Date().toISOString();
}

function serialize(value: unknown) {
  return JSON.stringify(value);
}

function parseJson<T>(value: string): T {
  return JSON.parse(value) as T;
}

The implementation must expose:

export function createD1Repository(db: D1Database): AppRepository {
  return {
    async createBrandTemplate(input) {
      const createdAt = nowIso();
      const template = {
        id: `brand_${nanoid(10)}`,
        ...input,
        created_at: createdAt,
        updated_at: createdAt,
      };

      await db
        .prepare(
          `insert into brand_templates (
            id, brand_name, company_full_name, company_short_names, product_names,
            target_industries, target_audience, verified_claims, forbidden_claims,
            tone_rules, created_at, updated_at
          ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        )
        .bind(
          template.id,
          template.brand_name,
          template.company_full_name,
          serialize(template.company_short_names),
          serialize(template.product_names),
          serialize(template.target_industries),
          serialize(template.target_audience),
          serialize(template.verified_claims),
          serialize(template.forbidden_claims),
          serialize(template.tone_rules),
          template.created_at,
          template.updated_at,
        )
        .run();

      return template;
    },
    async listBrandTemplates() {
      const result = await db
        .prepare("select * from brand_templates order by updated_at desc")
        .all<BrandTemplateRow>();
      return result.results.map(toBrandTemplate);
    },
    async getBrandTemplate(id) {
      const row = await db
        .prepare("select * from brand_templates where id = ?")
        .bind(id)
        .first<BrandTemplateRow>();
      return row ? toBrandTemplate(row) : null;
    },
    async createArticleJob(input) {
      const createdAt = nowIso();
      const job = {
        id: `job_${nanoid(10)}`,
        brand_template_id: input.brand_template_id ?? null,
        source_title: input.source_title,
        source_body: input.source_body,
        image_inputs: input.image_inputs,
        publish_platform: input.publish_platform,
        user_instructions: input.user_instructions,
        status: "draft",
        export_paths: {},
        created_at: createdAt,
        updated_at: createdAt,
      };

      await db
        .prepare(
          `insert into article_jobs (
            id, brand_template_id, source_title, source_body, image_inputs,
            publish_platform, user_instructions, status, export_paths, created_at, updated_at
          ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        )
        .bind(
          job.id,
          job.brand_template_id,
          job.source_title,
          job.source_body,
          serialize(job.image_inputs),
          job.publish_platform,
          job.user_instructions,
          job.status,
          serialize(job.export_paths),
          job.created_at,
          job.updated_at,
        )
        .run();

      return job;
    },
    async getArticleJob(id) {
      const row = await db
        .prepare("select * from article_jobs where id = ?")
        .bind(id)
        .first<ArticleJobRow>();
      return row ? toArticleJob(row) : null;
    },
    async updateArticleJob(id, changes) {
      const existing = await this.getArticleJob(id);
      if (!existing) return null;
      const updated = {
        brand_template_id: changes.brand_template_id ?? existing.brand_template_id,
        status: changes.status ?? existing.status,
        export_paths: changes.export_paths ?? existing.export_paths,
        updated_at: nowIso(),
      };
      await db
        .prepare(
          `update article_jobs set
            brand_template_id = ?,
            status = ?,
            export_paths = ?,
            updated_at = ?
           where id = ?`,
        )
        .bind(
          updated.brand_template_id,
          updated.status,
          serialize(updated.export_paths),
          updated.updated_at,
          id,
        )
        .run();
      return this.getArticleJob(id);
    },
    async saveFactCard(jobId, factCard) {
      const timestamp = nowIso();
      await db
        .prepare(
          `insert into fact_cards (
            job_id, source, fact_card, confirmed_by_user, created_at, updated_at
          ) values (?, ?, ?, ?, ?, ?)
          on conflict(job_id) do update set
            fact_card = excluded.fact_card,
            confirmed_by_user = excluded.confirmed_by_user,
            updated_at = excluded.updated_at`,
        )
        .bind(
          jobId,
          "auto_extract_then_user_confirmed",
          serialize(factCard),
          factCard.confirmed_by_user ? 1 : 0,
          timestamp,
          timestamp,
        )
        .run();
      return { job_id: jobId, ...factCard };
    },
    async getFactCard(jobId) {
      const row = await db
        .prepare("select job_id, fact_card from fact_cards where job_id = ?")
        .bind(jobId)
        .first<FactCardRow>();
      return row ? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) } : null;
    },
    async saveOptimizedArticle(jobId, article) {
      const latest = await this.getLatestOptimizedArticle(jobId);
      const revision = (latest?.revision ?? 0) + 1;
      const saved = { ...article, revision };
      await db
        .prepare(
          `insert into optimized_articles (job_id, revision, article, created_at)
           values (?, ?, ?, ?)`,
        )
        .bind(jobId, revision, serialize(saved), nowIso())
        .run();
      return saved;
    },
    async getLatestOptimizedArticle(jobId) {
      const row = await db
        .prepare(
          `select job_id, revision, article
           from optimized_articles
           where job_id = ?
           order by revision desc
           limit 1`,
        )
        .bind(jobId)
        .first<OptimizedArticleRow>();
      return row ? { ...parseJson<OptimizedArticle>(row.article), revision: row.revision } : null;
    },
    async saveQaReport(jobId, revision, report) {
      await db
        .prepare(
          `insert into qa_reports (job_id, revision, report, created_at)
           values (?, ?, ?, ?)
           on conflict(job_id, revision) do update set report = excluded.report`,
        )
        .bind(jobId, revision, serialize(report), nowIso())
        .run();
      return report;
    },
    async getQaReport(jobId, revision) {
      const row = await db
        .prepare("select job_id, revision, report from qa_reports where job_id = ? and revision = ?")
        .bind(jobId, revision)
        .first<QaReportRow>();
      return row ? parseJson<QaReport>(row.report) : null;
    },
  };
}
  • Step 4: Run D1 repository tests and verify GREEN

Run:

npm test -- src/lib/db/__tests__/d1-repository.test.ts

Expected: PASS.

Task 6: Add Runtime Repository Selection

Files:

  • Create: src/lib/runtime/cloudflare.ts

  • Modify: src/lib/db/repository.ts

  • Test: src/lib/db/__tests__/repository-selection.test.ts

  • Step 1: Write runtime selection tests

Create src/lib/db/__tests__/repository-selection.test.ts:

import { describe, expect, test } from "vitest";

import { getRepositoryFromRuntime } from "../repository";

describe("getRepositoryFromRuntime", () => {
  test("returns local repository when APP_RUNTIME is not cloudflare", () => {
    const repository = getRepositoryFromRuntime({
      appRuntime: "local",
      dbPath: ":memory:",
    });

    expect(repository).toBeDefined();
  });

  test("throws clearly for cloudflare runtime without D1 binding", () => {
    expect(() =>
      getRepositoryFromRuntime({
        appRuntime: "cloudflare",
        cloudflareEnv: {},
      }),
    ).toThrow("Cloudflare D1 binding DB is required");
  });
});
  • Step 2: Run runtime selection tests and verify RED

Run:

npm test -- src/lib/db/__tests__/repository-selection.test.ts

Expected: FAIL because getRepositoryFromRuntime does not exist.

  • Step 3: Implement runtime access helper

Create src/lib/runtime/cloudflare.ts:

import { getCloudflareContext } from "@opennextjs/cloudflare";

export interface AppCloudflareEnv {
  DB?: D1Database;
  EXPORT_BUCKET?: R2Bucket;
  API_ACCESS_KEY?: string;
}

export function getAppCloudflareEnv(): AppCloudflareEnv | null {
  if (process.env.APP_RUNTIME !== "cloudflare") {
    return null;
  }

  return getCloudflareContext().env as AppCloudflareEnv;
}
  • Step 4: Implement repository selection

Append to src/lib/db/repository.ts:

import { getAppCloudflareEnv, type AppCloudflareEnv } from "../runtime/cloudflare";
import { createD1Repository } from "./d1-repository";
import { createSqliteRepository } from "./sqlite-repository";

interface RuntimeRepositoryOptions {
  appRuntime?: string;
  dbPath?: string;
  cloudflareEnv?: AppCloudflareEnv;
}

export function getRepositoryFromRuntime(options: RuntimeRepositoryOptions = {}): AppRepository {
  const appRuntime = options.appRuntime ?? process.env.APP_RUNTIME;
  if (appRuntime === "cloudflare") {
    const env = options.cloudflareEnv ?? getAppCloudflareEnv();
    if (!env?.DB) {
      throw new Error("Cloudflare D1 binding DB is required");
    }
    return createD1Repository(env.DB);
  }

  return createSqliteRepository(options.dbPath);
}
  • Step 5: Run runtime selection tests and verify GREEN

Run:

npm test -- src/lib/db/__tests__/repository-selection.test.ts

Expected: PASS.

Task 7: Add Local And R2 Export Stores

Files:

  • Modify: src/lib/workflow/exporter.ts

  • Create: src/lib/workflow/export-store.ts

  • Test: src/lib/workflow/__tests__/export-store.test.ts

  • Step 1: Write export-store tests

Create src/lib/workflow/__tests__/export-store.test.ts:

import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import type { OptimizedArticle, QaReport } from "../../domain/types";
import { createLocalExportStore, createR2ExportStore } from "../export-store";

const article: OptimizedArticle = {
  title: "Example",
  summary: "Summary",
  body_markdown: "Body",
  image_suggestions: [],
  requires_user_confirmation: [],
  revision: 1,
};

const report: QaReport = {
  overall_status: "pass",
  checks: [],
  blocked_reasons: [],
};

describe("export stores", () => {
  let tempDir: string;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), "geo-export-store-"));
  });

  afterEach(() => {
    rmSync(tempDir, { recursive: true, force: true });
  });

  test("local store writes and reads exports", async () => {
    const store = createLocalExportStore(tempDir);

    const paths = await store.writeJobExports({
      jobId: "job_123",
      article,
      qaReport: report,
    });

    expect(paths.markdown).toContain("optimized.md");
    const file = await store.readJobExport("job_123", "optimized.md");
    expect(await file?.text()).toContain("# Example");
  });

  test("R2 store writes private export objects through binding", async () => {
    const put = vi.fn().mockResolvedValue(undefined);
    const bucket = { put } as unknown as R2Bucket;
    const store = createR2ExportStore(bucket);

    const paths = await store.writeJobExports({
      jobId: "job_123",
      article,
      qaReport: report,
    });

    expect(paths.markdown).toBe("r2://exports/job_123/optimized.md");
    expect(put).toHaveBeenCalledWith(
      "exports/job_123/optimized.md",
      expect.any(String),
      expect.objectContaining({
        httpMetadata: { contentType: "text/markdown; charset=utf-8" },
      }),
    );
  });
});
  • Step 2: Run export-store tests and verify RED

Run:

npm test -- src/lib/workflow/__tests__/export-store.test.ts

Expected: FAIL because export-store does not exist.

  • Step 3: Implement export-store boundary

Create src/lib/workflow/export-store.ts:

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

import { getAppDataDir } from "../db/connection";
import type { OptimizedArticle, QaReport } from "../domain/types";
import {
  renderOptimizedDocx,
  renderOptimizedMarkdown,
  renderQaReportJson,
} from "./exporter";

const CONTENT_TYPES: Record<string, string> = {
  "optimized.md": "text/markdown; charset=utf-8",
  "optimized.docx":
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "qa_report.json": "application/json; charset=utf-8",
};

export interface WriteJobExportsInput {
  jobId: string;
  article: OptimizedArticle;
  qaReport: QaReport;
}

export interface ExportFile {
  body: BodyInit;
  contentType: string;
}

export interface ExportStore {
  writeJobExports(input: WriteJobExportsInput): Promise<Record<string, string>>;
  readJobExport(jobId: string, fileName: string): Promise<Response | null>;
}

export function createLocalExportStore(dataDir = getAppDataDir()): ExportStore {
  return {
    async writeJobExports({ jobId, article, qaReport }) {
      const exportDir = join(dataDir, "exports", jobId);
      mkdirSync(exportDir, { recursive: true });

      const markdown = join(exportDir, "optimized.md");
      const docx = join(exportDir, "optimized.docx");
      const qaJson = join(exportDir, "qa_report.json");

      writeFileSync(markdown, renderOptimizedMarkdown(article), "utf8");
      writeFileSync(qaJson, renderQaReportJson(qaReport), "utf8");
      writeFileSync(docx, await renderOptimizedDocx(article));

      return { markdown, docx, qaJson };
    },
    async readJobExport(jobId, fileName) {
      const contentType = CONTENT_TYPES[fileName];
      if (!contentType) return null;

      const path = join(dataDir, "exports", jobId, fileName);
      if (!existsSync(path)) return null;

      return new Response(readFileSync(path), {
        headers: {
          "content-type": contentType,
          "content-disposition": `attachment; filename="${fileName}"`,
        },
      });
    },
  };
}

export function createR2ExportStore(bucket: R2Bucket): ExportStore {
  return {
    async writeJobExports({ jobId, article, qaReport }) {
      const prefix = `exports/${jobId}`;
      const markdownKey = `${prefix}/optimized.md`;
      const docxKey = `${prefix}/optimized.docx`;
      const qaJsonKey = `${prefix}/qa_report.json`;

      await bucket.put(markdownKey, renderOptimizedMarkdown(article), {
        httpMetadata: { contentType: CONTENT_TYPES["optimized.md"] },
      });
      await bucket.put(qaJsonKey, renderQaReportJson(qaReport), {
        httpMetadata: { contentType: CONTENT_TYPES["qa_report.json"] },
      });
      await bucket.put(docxKey, await renderOptimizedDocx(article), {
        httpMetadata: { contentType: CONTENT_TYPES["optimized.docx"] },
      });

      return {
        markdown: `r2://${markdownKey}`,
        docx: `r2://${docxKey}`,
        qaJson: `r2://${qaJsonKey}`,
      };
    },
    async readJobExport(jobId, fileName) {
      const contentType = CONTENT_TYPES[fileName];
      if (!contentType) return null;

      const object = await bucket.get(`exports/${jobId}/${fileName}`);
      if (!object) return null;

      return new Response(object.body, {
        headers: {
          "content-type": object.httpMetadata?.contentType ?? contentType,
          "content-disposition": `attachment; filename="${fileName}"`,
        },
      });
    },
  };
}
  • Step 4: Run export-store tests and verify GREEN

Run:

npm test -- src/lib/workflow/__tests__/export-store.test.ts

Expected: PASS.

Task 8: Wire Routes To Auth, Repository, And Export Store

Files:

  • Modify: src/app/api/jobs/route.ts

  • Modify: src/app/api/jobs/[jobId]/confirm-fact-card/route.ts

  • Modify: src/app/api/jobs/[jobId]/optimize/route.ts

  • Modify: src/app/api/jobs/[jobId]/exports/[fileName]/route.ts

  • Test: src/app/api/__tests__/jobs.test.ts

  • Step 1: Update API tests for API key enforcement

In src/app/api/__tests__/jobs.test.ts, add tests that call the route without x-api-key and expect 401, then call with x-api-key and expect the existing behavior. In test setup set:

const originalApiKey = process.env.API_ACCESS_KEY;
const originalAuthDisabled = process.env.API_AUTH_DISABLED;

beforeEach(() => {
  process.env.API_ACCESS_KEY = "test-key";
  process.env.API_AUTH_DISABLED = "false";
});

afterEach(() => {
  process.env.API_ACCESS_KEY = originalApiKey;
  process.env.API_AUTH_DISABLED = originalAuthDisabled;
});

For existing successful route calls, construct requests with:

headers: { "x-api-key": "test-key" }
  • Step 2: Run API tests and verify RED

Run:

npm test -- src/app/api/__tests__/jobs.test.ts

Expected: FAIL because routes do not call requireApiAccess yet.

  • Step 3: Update route handlers

In every API route, add this pattern at the start of each handler:

const access = requireApiAccess(request);
if (!access.ok) {
  return access.response;
}

Use _request only in handlers that do not need auth; after this change every API route needs request.

Replace direct repository calls with:

const repository = getRepositoryFromRuntime();

Then await calls:

const job = await repository.getArticleJob(jobId);

In optimize route, replace writeJobExports with runtime export store:

const exportStore = getExportStoreFromRuntime();
const exportPaths =
  qaReport.overall_status === "fail"
    ? {}
    : await exportStore.writeJobExports({
        jobId,
        article: optimizedArticle,
        qaReport,
      });

In export download route, replace filesystem reads with:

const exportStore = getExportStoreFromRuntime();
const response = await exportStore.readJobExport(jobId, fileName);
if (!response) {
  return NextResponse.json({ error: "Export file not found" }, { status: 404 });
}
return response;
  • Step 4: Add export-store runtime selector

Append this function to src/lib/workflow/export-store.ts:

import { getAppCloudflareEnv } from "../runtime/cloudflare";

export function getExportStoreFromRuntime(): ExportStore {
  if (process.env.APP_RUNTIME === "cloudflare") {
    const env = getAppCloudflareEnv();
    if (!env?.EXPORT_BUCKET) {
      throw new Error("Cloudflare R2 binding EXPORT_BUCKET is required");
    }
    return createR2ExportStore(env.EXPORT_BUCKET);
  }

  return createLocalExportStore();
}
  • Step 5: Run API tests and verify GREEN

Run:

npm test -- src/app/api/__tests__/jobs.test.ts

Expected: PASS.

Task 9: Document Manual Cloudflare Resource And Deployment Flow

Files:

  • Modify: README.md

  • Modify: .env.example

  • Modify: wrangler.jsonc

  • Step 1: Update .env.example

Replace the environment example with local-only values:

LLM_PROVIDER=deepseek
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-pro
DEEPSEEK_THINKING=disabled
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4.1-mini
APP_DATA_DIR=./data
API_ACCESS_KEY=local-dev-key
API_AUTH_DISABLED=false
  • Step 2: Update README deployment section

Add a Cloudflare section with this content. Use four-space indentation for nested command examples so the README renders cleanly:

## Cloudflare Workers Deployment

Cloudflare deployment is manual. Pushing to Git does not deploy or hot-update
the production Worker.

Local development:

    npm run dev

Cloudflare preview:

    cp .dev.vars.example .dev.vars
    npm run d1:migrate:local
    npm run preview:worker

Create private staging resources:

    npx wrangler d1 create geo-agent-article-optimizer-staging
    npx wrangler r2 bucket create geo-agent-article-optimizer-staging

Create private production resources:

    npx wrangler d1 create geo-agent-article-optimizer-production
    npx wrangler r2 bucket create geo-agent-article-optimizer-production

After D1 creation, copy the returned database IDs into the matching
`wrangler.jsonc` environment entries. Keep R2 buckets private; do not add
public bucket domains.

Set secrets:

    npx wrangler secret put API_ACCESS_KEY --env staging
    npx wrangler secret put DEEPSEEK_API_KEY --env staging
    npx wrangler secret put API_ACCESS_KEY --env production
    npx wrangler secret put DEEPSEEK_API_KEY --env production

Migration order:

    npm run d1:migrate:local
    npm run d1:migrate:staging
    npm run deploy:worker:staging
    npm run d1:migrate:production
    npm run deploy:worker:production

All API requests require:

    x-api-key: <API_ACCESS_KEY>
  • Step 3: Verify docs mention no runtime schema mutation

Run:

rg -n "migrations|automatic|API_ACCESS_KEY|private|hot-update|hot update|x-api-key" README.md docs/superpowers/specs/2026-06-16-cloudflare-workers-deployment-design.md

Expected: output includes D1 migrations, private R2, manual deployment, and API key language.

  • Step 4: Leave remote env blocks out until real resource IDs exist

If the executor cannot create Cloudflare resources in this session, keep env as {} in wrangler.jsonc. Do not commit fake staging or production database_id values. The README documents the exact commands for creating resources and adding remote env blocks after Wrangler returns real database IDs.

Expected: wrangler.jsonc contains no fake staging or production database IDs.

Task 10: Full Verification

Files:

  • All modified files

  • Step 1: Run focused tests

Run:

npm test -- src/lib/api/__tests__/auth.test.ts src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/repository-selection.test.ts src/lib/db/__tests__/d1-repository.test.ts src/lib/workflow/__tests__/export-store.test.ts src/app/api/__tests__/jobs.test.ts

Expected: PASS.

  • Step 2: Run full test suite

Run:

npm test

Expected: PASS.

  • Step 3: Run regular Next.js build

Run:

npm run build

Expected: exit 0.

  • Step 4: Run Worker build

Run:

npm run build:worker

Expected: exit 0 and .open-next/worker.js exists.

  • Step 5: Run Wrangler dry run

Run:

npx wrangler deploy --dry-run

Expected: Wrangler validates the Worker bundle. If staging/production IDs are not configured yet, dry-run top-level local config still validates.

  • Step 6: Inspect for forbidden runtime schema changes

Run:

rg -n "CREATE TABLE|DROP TABLE|DELETE FROM|initializeSchema" src wrangler.jsonc migrations

Expected: CREATE TABLE only appears in migrations/0001_initial_schema.sql and local SQLite test/setup code. No Cloudflare runtime file creates, drops, or clears tables.

  • Step 7: Commit implementation

Run:

git add .gitignore .dev.vars.example README.md next.config.ts open-next.config.ts package.json package-lock.json public/_headers wrangler.jsonc migrations src docs/superpowers
git commit -m "feat: add cloudflare workers deployment path"

Expected: commit succeeds without staging unrelated .DS_Store.