# 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: ```bash 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: ```json { "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`: ```gitignore .open-next .wrangler .dev.vars cloudflare-env.d.ts ``` - [ ] **Step 4: Add OpenNext config** Create `open-next.config.ts`: ```typescript import { defineCloudflareConfig } from "@opennextjs/cloudflare"; export default defineCloudflareConfig({}); ``` - [ ] **Step 5: Initialize OpenNext dev support in `next.config.ts`** Replace `next.config.ts` with: ```typescript 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. ```jsonc { "$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`: ```text /_next/static/* Cache-Control: public,max-age=31536000,immutable ``` - [ ] **Step 8: Add local Worker env example** Create `.dev.vars.example`: ```text NEXTJS_ENV=development API_ACCESS_KEY=local-worker-dev-key DEEPSEEK_API_KEY= OPENAI_API_KEY= ``` - [ ] **Step 9: Generate Cloudflare binding types** Run: ```bash 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`: ```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: ```bash 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`: ```typescript 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: ```bash 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`: ```typescript 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: ```bash 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`: ```typescript 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: ```bash 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`: ```typescript import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types"; import type { ArticleJob, BrandTemplate, NewArticleJob, NewBrandTemplate, } from "./repositories"; export interface AppRepository { createBrandTemplate(input: NewBrandTemplate): Promise; listBrandTemplates(): Promise; getBrandTemplate(id: string): Promise; createArticleJob(input: NewArticleJob): Promise; getArticleJob(id: string): Promise; updateArticleJob( id: string, changes: Partial>, ): Promise; 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; getLatestOptimizedArticle(jobId: string): Promise; saveQaReport(jobId: string, revision: number, report: QaReport): Promise; getQaReport(jobId: string, revision: number): Promise; } ``` - [ ] **Step 4: Create local SQLite adapter** Create `src/lib/db/sqlite-repository.ts`: ```typescript 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>, ) { 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: ```bash 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: ```typescript 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: ```bash 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()`, and `all()`. Do not include schema creation or destructive SQL in this file. The implementation must include these helpers: ```typescript function nowIso() { return new Date().toISOString(); } function serialize(value: unknown) { return JSON.stringify(value); } function parseJson(value: string): T { return JSON.parse(value) as T; } ``` The implementation must expose: ```typescript 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(); return result.results.map(toBrandTemplate); }, async getBrandTemplate(id) { const row = await db .prepare("select * from brand_templates where id = ?") .bind(id) .first(); 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(); 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(); return row ? { job_id: row.job_id, ...parseJson(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(); return row ? { ...parseJson(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(); return row ? parseJson(row.report) : null; }, }; } ``` - [ ] **Step 4: Run D1 repository tests and verify GREEN** Run: ```bash 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`: ```typescript 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: ```bash 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`: ```typescript 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`: ```typescript 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: ```bash 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`: ```typescript 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: ```bash 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`: ```typescript 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 = { "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>; readJobExport(jobId: string, fileName: string): Promise; } 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: ```bash 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: ```typescript 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: ```typescript headers: { "x-api-key": "test-key" } ``` - [ ] **Step 2: Run API tests and verify RED** Run: ```bash 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: ```typescript 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: ```typescript const repository = getRepositoryFromRuntime(); ``` Then await calls: ```typescript const job = await repository.getArticleJob(jobId); ``` In optimize route, replace `writeJobExports` with runtime export store: ```typescript const exportStore = getExportStoreFromRuntime(); const exportPaths = qaReport.overall_status === "fail" ? {} : await exportStore.writeJobExports({ jobId, article: optimizedArticle, qaReport, }); ``` In export download route, replace filesystem reads with: ```typescript 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`: ```typescript 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: ```bash 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: ```text 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: - [ ] **Step 3: Verify docs mention no runtime schema mutation** Run: ```bash 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: ```bash 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: ```bash npm test ``` Expected: PASS. - [ ] **Step 3: Run regular Next.js build** Run: ```bash npm run build ``` Expected: exit 0. - [ ] **Step 4: Run Worker build** Run: ```bash npm run build:worker ``` Expected: exit 0 and `.open-next/worker.js` exists. - [ ] **Step 5: Run Wrangler dry run** Run: ```bash 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: ```bash 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: ```bash 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`.