UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

1,350 lines (1,057 loc) 37.3 kB
--- title: Separate Copy dimension: things category: plans tags: architecture, auth, backend, frontend, knowledge, testing related_dimensions: connections, knowledge scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the things dimension in the plans category. Location: one/things/plans/separate-copy.md Purpose: Documents frontend-backend separation plan Related dimensions: connections, knowledge For AI agents: Read this to understand separate copy. --- # Frontend-Backend Separation Plan ## Executive Summary **Goal:** Transform the current tightly-coupled architecture into a fully headless, API-first architecture where: - Frontend: Pure Astro/React UI (no Convex dependency) - Backend: Hono API + Convex (standalone service) - Connection: REST API with API key authentication **Current State:** Frontend directly imports Convex hooks (`useQuery`, `useMutation`) and calls Convex functions. **Target State:** Frontend only knows about REST API endpoints, authenticates with API keys, and has zero knowledge of Convex. --- ## Table of Contents 1. [Architecture Comparison](#architecture-comparison) 2. [Benefits of Separation](#benefits-of-separation) 3. [Migration Strategy](#migration-strategy) 4. [API Key Authentication](#api-key-authentication) 5. [File Structure Changes](#file-structure-changes) 6. [Implementation Steps](#implementation-steps) 7. [Testing Strategy](#testing-strategy) 8. [Deployment Changes](#deployment-changes) --- ## Architecture Comparison ### Current Architecture (Coupled) ``` ┌─────────────────────────────────────────────────────────┐ FRONTEND (Astro + React) ┌──────────────────────────────────────────────────┐ Pages & Components ├─ import { useQuery } from 'convex/react' ├─ import { api } from 'convex/_generated/api' └─ const data = useQuery(api.entities.get) └──────────────────────────────────────────────────┘ frontend/convex/* (schema, mutations, queries) Direct WebSocket Connection └─────────────────────────────────────────────────────────┘ ┌───────────────────────┐ CONVEX BACKEND (Real-time DB) └───────────────────────┘ ``` **Problems:** - Frontend tightly coupled to Convex - Can't swap backend without rewriting frontend - Hard to version API (breaking changes impact frontend immediately) - No clear API boundary - Can't reuse backend for mobile/desktop apps without Convex SDK - Multi-tenancy requires each frontend to have own Convex deployment --- ### Target Architecture (Separated) ``` ┌─────────────────────────────────────────────────────────┐ FRONTEND (Headless) ┌──────────────────────────────────────────────────┐ Pages & Components ├─ import { apiClient } from '@/lib/api' ├─ const data = await api.tokens.get(id) └─ No Convex imports! └──────────────────────────────────────────────────┘ HTTP Requests with API Key Authorization: Bearer sk_live_xxx └─────────────────────────────────────────────────────────┘ HTTPS/REST ┌─────────────────────────────────────────────────────────┐ BACKEND API (Hono + Convex) ┌──────────────────────────────────────────────────┐ API Routes (Hono on Cloudflare Workers) /api/auth/* - Authentication /api/tokens/* - Token operations /api/agents/* - Agent management /api/content/*- Content CRUD └────────────────┬─────────────────────────────────┘ ┌────────────────┴─────────────────────────────────┐ Middleware ├─ API Key Validation ├─ Rate Limiting ├─ CORS └─ Request Logging └────────────────┬─────────────────────────────────┘ ┌────────────────┴─────────────────────────────────┐ Business Logic (Effect.ts Services) - TokenService, AgentService, etc. - Pure functional logic - Type-safe error handling └────────────────┬─────────────────────────────────┘ ┌────────────────┴─────────────────────────────────┐ Data Layer (ConvexHttpClient) - Queries/mutations via HTTP - 6-dimension ontology access └────────────────┬─────────────────────────────────┘ └───────────────────┼─────────────────────────────────────┘ ┌───────────────────────┐ CONVEX BACKEND - entities - connections - events - knowledge - API keys (stored) └───────────────────────┘ ``` **Benefits:** - Frontend can be swapped/rebuilt independently - Backend API can serve web, mobile, desktop, CLI - Clear API versioning (/api/v1, /api/v2) - API keys enable multi-tenancy (one backend, many frontends) - Standard REST patterns (easy for any dev to understand) - Can rate limit, monitor, version API independently --- ## Benefits of Separation ### 1. Multi-Tenancy Support **Before:** ``` Org A Frontend A Convex Deployment A Org B Frontend B Convex Deployment B Org C Frontend C Convex Deployment C 3 orgs = 3 Convex deployments (expensive, hard to manage) ``` **After:** ``` Org A Frontend A ──┐ ├→ Single Backend API Single Convex Deployment Org B Frontend B ──┤ (with API key isolation) Org C Frontend C ──┘ 3 orgs = 1 Convex deployment (cheap, centralized) ``` ### 2. Platform Independence **Before:** - Web: Must use Convex React hooks - Mobile: Must use Convex React Native SDK - Desktop: Must use Convex Electron SDK - CLI: Must use Convex Node SDK Each platform needs Convex SDK **After:** - Web: HTTP fetch() or axios - Mobile: HTTP client (works with any framework) - Desktop: HTTP client (works with any framework) - CLI: curl or HTTP client Any platform that speaks HTTP works ### 3. Team Organization **Before:** ``` Full-stack developer needs to know: - Astro - React - Convex schema - Convex queries/mutations - Convex actions - Effect.ts - Business logic ``` **After:** ``` Frontend developer needs to know: - Astro - React - API endpoints (documented) Backend developer needs to know: - Hono routes - Effect.ts - Convex schema - Business logic ``` Clear separation of concerns ### 4. API Evolution **Before:** ``` Change Convex schema Frontend breaks immediately No versioning, no backwards compatibility ``` **After:** ``` /api/v1/tokens Stable, never breaks /api/v2/tokens New version with improvements Frontend chooses which version to use Graceful deprecation, backwards compatible ``` --- ## Migration Strategy ### Phase 1: Backend API Creation (No Frontend Changes) **Goal:** Build standalone Hono API that mirrors current Convex functions **Tasks:** 1. Create `/backend/api/` directory 2. Implement Hono routes for all Convex queries/mutations 3. Add API key authentication middleware 4. Deploy to Cloudflare Workers 5. Test all endpoints with Postman/curl **Timeline:** 1-2 weeks **Risk:** Low (frontend still works with Convex) --- ### Phase 2: Frontend API Client Library **Goal:** Create abstraction layer in frontend that can switch between Convex and HTTP **Tasks:** 1. Create `/frontend/src/lib/api/client.ts` 2. Implement API client with same interface as Convex hooks 3. Add environment variable toggle: `USE_API_BACKEND=true/false` 4. Test dual mode (frontend works with both) **Timeline:** 1 week **Risk:** Low (can toggle back to Convex if issues) --- ### Phase 3: Gradual Migration **Goal:** Migrate frontend page by page from Convex hooks to API client **Tasks:** 1. Migrate `/blog` pages first (low risk) 2. Migrate `/tokens` pages 3. Migrate `/agents` pages 4. Migrate authentication pages last (highest risk) **Timeline:** 2-3 weeks **Risk:** Medium (careful testing needed per page) --- ### Phase 4: Remove Convex from Frontend **Goal:** Delete `frontend/convex/*` and all Convex dependencies **Tasks:** 1. Verify all pages use API client 2. Remove Convex imports from `package.json` 3. Delete `frontend/convex/` directory 4. Update build process **Timeline:** 1 week **Risk:** Low (if Phase 3 done correctly) --- ## API Key Authentication ### Key Types **1. Secret Keys (Backend-to-Backend)** ``` sk_live_1234567890abcdef sk_test_1234567890abcdef ``` **2. Publishable Keys (Frontend-Safe)** ``` pk_live_1234567890abcdef pk_test_1234567890abcdef ``` ### Database Schema **New Entity Type: `api_key`** ```typescript { type: "api_key", name: "Production API Key for Org A", properties: { keyPrefix: "sk_live", // or "sk_test", "pk_live", "pk_test" keyHash: "sha256(...)", // Hashed key (never store plaintext!) keyHint: "...def", // Last 3 chars for display orgId: "org_123", // Which org owns this key scopes: [ // What can this key do? "tokens:read", "tokens:write", "agents:read", "agents:write" ], rateLimit: { requests: 1000, // Max requests per minute period: 60 // Period in seconds }, expiresAt: 1735689600000, // Optional expiration lastUsedAt: null, createdBy: "user_123", environment: "production" // or "development" }, status: "active", // or "revoked" createdAt: 1704067200000, updatedAt: 1704067200000 } ``` ### Middleware Implementation **Backend: API Key Validation Middleware** ```typescript // backend/api/middleware/auth.ts import { Context, Next } from "hono"; import { createHash } from "crypto"; import { ConvexHttpClient } from "convex/browser"; import { api } from "convex/_generated/api"; export async function validateApiKey(c: Context, next: Next) { // Extract API key from header const authHeader = c.req.header("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { return c.json({ error: "Missing API key" }, 401); } const apiKey = authHeader.replace("Bearer ", ""); // Validate key format if (!apiKey.match(/^(sk|pk)_(live|test)_[a-zA-Z0-9]{24}$/)) { return c.json({ error: "Invalid API key format" }, 401); } // Hash the key const keyHash = createHash("sha256").update(apiKey).digest("hex"); // Query Convex for matching key const convex = new ConvexHttpClient(process.env.CONVEX_URL!); const keyEntity = await convex.query(api.queries.apiKeys.validate, { keyHash, }); if (!keyEntity) { return c.json({ error: "Invalid API key" }, 401); } if (keyEntity.status !== "active") { return c.json({ error: "API key revoked" }, 401); } // Check expiration if ( keyEntity.properties.expiresAt && Date.now() > keyEntity.properties.expiresAt ) { return c.json({ error: "API key expired" }, 401); } // Check scopes (if route requires specific scope) const requiredScope = c.req.param("scope"); if (requiredScope && !keyEntity.properties.scopes.includes(requiredScope)) { return c.json({ error: "Insufficient permissions" }, 403); } // Rate limiting check const rateLimitKey = `rate_limit:${keyEntity._id}`; const currentCount = await getRateLimitCount(rateLimitKey); if (currentCount >= keyEntity.properties.rateLimit.requests) { return c.json({ error: "Rate limit exceeded" }, 429); } // Increment rate limit counter await incrementRateLimit(rateLimitKey, keyEntity.properties.rateLimit.period); // Update last used timestamp (async, don't wait) convex .mutation(api.mutations.apiKeys.updateLastUsed, { keyId: keyEntity._id, }) .catch(console.error); // Attach org context to request c.set("orgId", keyEntity.properties.orgId); c.set("keyScopes", keyEntity.properties.scopes); c.set("apiKeyId", keyEntity._id); await next(); } ``` **Frontend: API Client with Key** ```typescript // frontend/src/lib/api/client.ts export class ApiClient { private baseUrl: string; private apiKey: string; constructor(apiKey: string, baseUrl?: string) { this.apiKey = apiKey; this.baseUrl = baseUrl || import.meta.env.PUBLIC_API_URL; } private async request<T>( endpoint: string, options: RequestInit = {}, ): Promise<T> { const url = `${this.baseUrl}${endpoint}`; const response = await fetch(url, { ...options, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, ...options.headers, }, }); if (!response.ok) { const error = await response.json(); throw new ApiError(error.error, response.status); } return response.json(); } // Tokens API tokens = { get: (id: string) => this.request<Token>(`/api/v1/tokens/${id}`), list: (params?: { orgId?: string; limit?: number }) => this.request<Token[]>(`/api/v1/tokens?${new URLSearchParams(params)}`), create: (data: CreateTokenInput) => this.request<Token>("/api/v1/tokens", { method: "POST", body: JSON.stringify(data), }), purchase: (id: string, amount: number) => this.request<PurchaseResult>(`/api/v1/tokens/${id}/purchase`, { method: "POST", body: JSON.stringify({ amount }), }), }; // Agents API agents = { get: (id: string) => this.request<Agent>(`/api/v1/agents/${id}`), list: () => this.request<Agent[]>("/api/v1/agents"), create: (data: CreateAgentInput) => this.request<Agent>("/api/v1/agents", { method: "POST", body: JSON.stringify(data), }), }; // Add more resource methods... } // Export singleton with environment API key export const api = new ApiClient(import.meta.env.PUBLIC_API_KEY || ""); ``` **Usage in Frontend:** ```astro --- // frontend/src/pages/tokens/[id].astro import { api } from "@/lib/api/client"; import Layout from "@/layouts/Layout.astro"; const { id } = Astro.params; const token = await api.tokens.get(id); --- <Layout title={token.name}> <h1>{token.name}</h1> <p>Balance: {token.properties.balance}</p> </Layout> ``` --- ## File Structure Changes ### Before (Coupled) ``` frontend/ ├── convex/ # ❌ Remove entire directory ├── _generated/ ├── mutations/ ├── queries/ ├── schema.ts └── http.ts ├── src/ ├── components/ └── TokenCard.tsx # Uses useQuery(api.tokens.get) └── pages/ └── tokens/[id].astro # Uses ConvexHttpClient └── package.json # Includes "convex": "^1.x.x" backend/ └── convex/ # Only Convex deployment ``` ### After (Separated) ``` frontend/ ├── src/ ├── lib/ └── api/ ├── client.ts # ✅ API client (no Convex) ├── types.ts # ✅ Type definitions └── errors.ts # ✅ Error handling ├── components/ └── TokenCard.tsx # ✅ Uses api.tokens.get() └── pages/ └── tokens/[id].astro # ✅ Uses api.tokens.get() ├── .env # PUBLIC_API_KEY=pk_live_xxx └── package.json # ❌ No Convex dependency backend/ ├── api/ # ✅ New Hono API ├── routes/ ├── tokens.ts # /api/v1/tokens/* ├── agents.ts # /api/v1/agents/* ├── auth.ts # /api/v1/auth/* └── index.ts # Route aggregation ├── middleware/ ├── auth.ts # API key validation ├── cors.ts # CORS handling ├── rateLimit.ts # Rate limiting └── logging.ts # Request logging ├── services/ # Effect.ts business logic ├── tokens/ ├── agents/ └── auth/ └── index.ts # Hono app entry point ├── convex/ # Existing Convex backend ├── queries/ └── apiKeys.ts # ✅ New: API key queries ├── mutations/ └── apiKeys.ts # ✅ New: API key mutations └── schema.ts # ✅ Add api_key entity type └── wrangler.toml # Cloudflare Workers config ``` --- ## Implementation Steps ### Step 1: Create API Key Entity Type **File: `backend/convex/schema.ts`** Add to existing schema: ```typescript export default defineSchema({ // ... existing entities table entities: defineTable({ type: v.string(), // Add "api_key" as new type name: v.string(), properties: v.any(), status: v.optional( v.union( v.literal("active"), v.literal("inactive"), v.literal("draft"), v.literal("published"), v.literal("archived"), v.literal("revoked"), // Add for API keys ), ), createdAt: v.number(), updatedAt: v.number(), deletedAt: v.optional(v.number()), }) .index("by_type", ["type"]) .index("by_status", ["status"]) .index("by_key_hash", ["properties.keyHash"]), // Add for fast lookup // ... rest of schema }); ``` ### Step 2: Create API Key Queries **File: `backend/convex/queries/apiKeys.ts`** ```typescript import { query } from "../_generated/server"; import { v } from "convex/values"; export const validate = query({ args: { keyHash: v.string() }, handler: async (ctx, args) => { const apiKey = await ctx.db .query("entities") .withIndex("by_key_hash", (q) => q.eq("properties.keyHash", args.keyHash)) .filter((q) => q.eq(q.field("type"), "api_key")) .first(); return apiKey; }, }); export const listByOrg = query({ args: { orgId: v.string() }, handler: async (ctx, args) => { const keys = await ctx.db .query("entities") .withIndex("by_type", (q) => q.eq("type", "api_key")) .filter((q) => q.eq(q.field("properties.orgId"), args.orgId)) .collect(); // Don't return keyHash (security) return keys.map((key) => ({ ...key, properties: { ...key.properties, keyHash: undefined, // Remove sensitive data }, })); }, }); ``` ### Step 3: Create API Key Mutations **File: `backend/convex/mutations/apiKeys.ts`** ```typescript import { mutation } from "../_generated/server"; import { v } from "convex/values"; import { createHash, randomBytes } from "crypto"; export const create = mutation({ args: { orgId: v.string(), name: v.string(), scopes: v.array(v.string()), environment: v.union(v.literal("production"), v.literal("development")), }, handler: async (ctx, args) => { // Generate random API key const prefix = args.environment === "production" ? "sk_live" : "sk_test"; const random = randomBytes(18).toString("base64url"); const apiKey = `${prefix}_${random}`; // Hash the key for storage const keyHash = createHash("sha256").update(apiKey).digest("hex"); const keyHint = apiKey.slice(-3); // Create entity const keyId = await ctx.db.insert("entities", { type: "api_key", name: args.name, properties: { keyPrefix: prefix, keyHash, keyHint, orgId: args.orgId, scopes: args.scopes, rateLimit: { requests: 1000, period: 60, }, environment: args.environment, lastUsedAt: null, }, status: "active", createdAt: Date.now(), updatedAt: Date.now(), }); // Return plaintext key ONLY this once return { id: keyId, apiKey, // ⚠️ Show user - never shown again! keyHint, }; }, }); export const revoke = mutation({ args: { keyId: v.id("entities") }, handler: async (ctx, args) => { await ctx.db.patch(args.keyId, { status: "revoked", updatedAt: Date.now(), }); }, }); export const updateLastUsed = mutation({ args: { keyId: v.id("entities") }, handler: async (ctx, args) => { const key = await ctx.db.get(args.keyId); if (!key) return; await ctx.db.patch(args.keyId, { properties: { ...key.properties, lastUsedAt: Date.now(), }, updatedAt: Date.now(), }); }, }); ``` ### Step 4: Create Hono API Backend **File: `backend/api/index.ts`** ```typescript import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { validateApiKey } from "./middleware/auth"; import tokensRoutes from "./routes/tokens"; import agentsRoutes from "./routes/agents"; import authRoutes from "./routes/auth"; const app = new Hono(); // Middleware app.use("*", logger()); app.use( "/api/*", cors({ origin: [ "http://localhost:4321", "https://*.pages.dev", process.env.FRONTEND_URL || "", ].filter(Boolean), credentials: true, }), ); // Health check (no auth required) app.get("/health", (c) => c.json({ status: "ok" })); // API routes (auth required) app.use("/api/*", validateApiKey); app.route("/api/v1/tokens", tokensRoutes); app.route("/api/v1/agents", agentsRoutes); app.route("/api/v1/auth", authRoutes); // 404 handler app.notFound((c) => c.json({ error: "Not found" }, 404)); // Error handler app.onError((err, c) => { console.error(err); return c.json({ error: "Internal server error" }, 500); }); export default app; ``` **File: `backend/api/routes/tokens.ts`** ```typescript import { Hono } from "hono"; import { ConvexHttpClient } from "convex/browser"; import { api } from "../../convex/_generated/api"; const app = new Hono(); const convex = new ConvexHttpClient(process.env.CONVEX_URL!); // GET /api/v1/tokens/:id app.get("/:id", async (c) => { const tokenId = c.req.param("id"); const orgId = c.get("orgId"); // From API key middleware const token = await convex.query(api.queries.entities.get, { id: tokenId }); if (!token || token.type !== "token") { return c.json({ error: "Token not found" }, 404); } // Check ownership if (token.properties.orgId !== orgId) { return c.json({ error: "Unauthorized" }, 403); } return c.json(token); }); // POST /api/v1/tokens/:id/purchase app.post("/:id/purchase", async (c) => { const tokenId = c.req.param("id"); const { amount } = await c.req.json(); const orgId = c.get("orgId"); // Call Convex mutation const result = await convex.mutation(api.mutations.tokens.purchase, { tokenId, amount, orgId, }); return c.json(result); }); // Add more routes... export default app; ``` ### Step 5: Create Frontend API Client **File: `frontend/src/lib/api/client.ts`** ```typescript export class ApiError extends Error { constructor( message: string, public status: number, ) { super(message); this.name = "ApiError"; } } export class ApiClient { private baseUrl: string; private apiKey: string; constructor(apiKey: string, baseUrl?: string) { this.apiKey = apiKey; this.baseUrl = baseUrl || import.meta.env.PUBLIC_API_URL; } private async request<T>( endpoint: string, options: RequestInit = {}, ): Promise<T> { const url = `${this.baseUrl}${endpoint}`; const response = await fetch(url, { ...options, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, ...options.headers, }, }); if (!response.ok) { const error = await response.json(); throw new ApiError(error.error, response.status); } return response.json(); } tokens = { get: (id: string) => this.request<Token>(`/api/v1/tokens/${id}`), purchase: (id: string, amount: number) => this.request<PurchaseResult>(`/api/v1/tokens/${id}/purchase`, { method: "POST", body: JSON.stringify({ amount }), }), }; agents = { get: (id: string) => this.request<Agent>(`/api/v1/agents/${id}`), list: () => this.request<Agent[]>("/api/v1/agents"), }; } // Singleton instance export const api = new ApiClient(import.meta.env.PUBLIC_API_KEY || ""); ``` ### Step 6: Update Frontend Pages **Before:** ```astro --- // frontend/src/pages/tokens/[id].astro import { ConvexHttpClient } from "convex/browser"; import { api as convexApi } from "@/convex/_generated/api"; const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL); const token = await convex.query(convexApi.entities.get, { id: Astro.params.id }); --- ``` **After:** ```astro --- // frontend/src/pages/tokens/[id].astro import { api } from "@/lib/api/client"; const token = await api.tokens.get(Astro.params.id); --- ``` ### Step 7: Remove Convex from Frontend **Delete:** ```bash rm -rf frontend/convex/ rm frontend/convex.config.ts ``` **Update `package.json`:** ```diff { "dependencies": { - "convex": "^1.x.x", - "@convex-dev/resend": "^x.x.x", "astro": "^5.14.0" } } ``` --- ## Testing Strategy ### 1. Backend API Tests ```typescript // backend/api/__tests__/tokens.test.ts import { describe, it, expect } from "vitest"; import app from "../index"; describe("Tokens API", () => { it("should require API key", async () => { const res = await app.request("/api/v1/tokens/123"); expect(res.status).toBe(401); }); it("should fetch token with valid key", async () => { const res = await app.request("/api/v1/tokens/123", { headers: { Authorization: `Bearer ${process.env.TEST_API_KEY}`, }, }); expect(res.status).toBe(200); }); it("should enforce org isolation", async () => { const res = await app.request("/api/v1/tokens/org-b-token", { headers: { Authorization: `Bearer ${process.env.ORG_A_API_KEY}`, }, }); expect(res.status).toBe(403); }); }); ``` ### 2. Frontend Integration Tests ```typescript // frontend/src/lib/api/__tests__/client.test.ts import { describe, it, expect, vi } from "vitest"; import { ApiClient } from "../client"; describe("ApiClient", () => { it("should add Authorization header", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "123" }), }); global.fetch = fetchMock; const client = new ApiClient("sk_test_123"); await client.tokens.get("123"); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer sk_test_123", }), }), ); }); it("should throw ApiError on failure", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: "Not found" }), }); const client = new ApiClient("sk_test_123"); await expect(client.tokens.get("999")).rejects.toThrow("Not found"); }); }); ``` --- ## Deployment Changes ### Before (Coupled) ``` 1. Deploy frontend to Cloudflare Pages - Includes Convex SDK - WebSocket connection to Convex 2. Deploy Convex backend - convex deploy ``` ### After (Separated) ``` 1. Deploy backend API to Cloudflare Workers - cd backend/api - wrangler deploy - Output: https://api.yourdomain.com 2. Deploy Convex backend - cd backend/convex - convex deploy - Output: https://your-deployment.convex.cloud 3. Deploy frontend to Cloudflare Pages - cd frontend - Set env: PUBLIC_API_URL=https://api.yourdomain.com - Set env: PUBLIC_API_KEY=pk_live_xxx - Build and deploy ``` ### Environment Variables **Backend (`backend/api/.env`):** ```bash CONVEX_URL=https://your-deployment.convex.cloud FRONTEND_URL=https://yourdomain.com ``` **Frontend (`frontend/.env`):** ```bash PUBLIC_API_URL=https://api.yourdomain.com PUBLIC_API_KEY=pk_live_1234567890abcdef ``` --- ## Security Considerations ### 1. API Key Storage **❌ Never:** - Store API keys in git - Use secret keys (`sk_*`) in frontend - Log API keys in console - Return key hash in API responses **✅ Always:** - Use publishable keys (`pk_*`) in frontend - Use secret keys (`sk_*`) only in backend - Hash keys before storing (SHA-256) - Show plaintext key only once at creation - Rotate keys regularly ### 2. Rate Limiting **Implementation:** ```typescript // backend/api/middleware/rateLimit.ts import { Context, Next } from "hono"; const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); export async function rateLimit(c: Context, next: Next) { const keyId = c.get("apiKeyId"); const limit = c.get("keyRateLimit"); const now = Date.now(); const key = `rate_limit:${keyId}`; const current = rateLimitMap.get(key); if (current && now < current.resetAt) { if (current.count >= limit.requests) { return c.json( { error: "Rate limit exceeded", resetAt: new Date(current.resetAt).toISOString(), }, 429, ); } current.count++; } else { rateLimitMap.set(key, { count: 1, resetAt: now + limit.period * 1000, }); } await next(); } ``` ### 3. CORS Configuration **Backend:** ```typescript app.use( "/api/*", cors({ origin: (origin) => { // Allow specific domains const allowed = [ "http://localhost:4321", "https://yourdomain.com", "https://*.pages.dev", ]; return allowed.some((pattern) => new RegExp(pattern.replace("*", ".*")).test(origin), ) ? origin : null; }, credentials: true, }), ); ``` --- ## Migration Checklist ### Backend Setup - [ ] Add `api_key` entity type to schema - [ ] Create API key queries in `backend/convex/queries/apiKeys.ts` - [ ] Create API key mutations in `backend/convex/mutations/apiKeys.ts` - [ ] Deploy schema changes to Convex - [ ] Create test API key via mutation - [ ] Create `backend/api/` directory structure - [ ] Implement Hono routes for all resources - [ ] Add API key validation middleware - [ ] Add rate limiting middleware - [ ] Add CORS middleware - [ ] Deploy API to Cloudflare Workers - [ ] Test all endpoints with Postman/curl - [ ] Set up monitoring and logging ### Frontend Setup - [ ] Create `frontend/src/lib/api/client.ts` - [ ] Create `frontend/src/lib/api/types.ts` - [ ] Create `frontend/src/lib/api/errors.ts` - [ ] Add `PUBLIC_API_URL` to `.env` - [ ] Add `PUBLIC_API_KEY` to `.env` - [ ] Update 1 page to test API client - [ ] Verify API client works in development - [ ] Migrate all pages to use API client - [ ] Remove all Convex imports from components - [ ] Delete `frontend/convex/` directory - [ ] Remove Convex from `package.json` - [ ] Update build process - [ ] Test full frontend in production ### Documentation - [ ] Update `CLAUDE.md` with new architecture - [ ] Document API endpoints (OpenAPI/Swagger) - [ ] Create API key management guide - [ ] Update deployment instructions - [ ] Create troubleshooting guide --- ## Rollback Plan If separation fails, rollback is easy: 1. Keep `frontend/convex/` in git (don't delete until verified) 2. Use environment variable to toggle: ```typescript const USE_API_BACKEND = import.meta.env.PUBLIC_USE_API_BACKEND === "true"; const data = USE_API_BACKEND ? await api.tokens.get(id) : await convex.query(api.entities.get, { id }); ``` 3. If issues arise, set `PUBLIC_USE_API_BACKEND=false` --- ## Success Metrics **Technical:** - [ ] Frontend has zero Convex dependencies - [ ] All API endpoints return < 200ms - [ ] Rate limiting enforces limits correctly - [ ] API key validation works for all scopes - [ ] CORS configured correctly - [ ] 100% test coverage on API routes **Business:** - [ ] Multiple frontends can use same backend - [ ] API versioning supports breaking changes gracefully - [ ] Multi-tenancy works (org isolation) - [ ] Mobile/desktop can use same API - [ ] API documentation is complete --- ## Timeline **Total Duration:** 6-8 weeks **Week 1-2:** Backend API creation **Week 3:** Frontend API client library **Week 4-5:** Gradual frontend migration **Week 6:** Remove Convex from frontend **Week 7:** Testing and bug fixes **Week 8:** Documentation and deployment --- ## Next Steps 1. **Review this plan** with team 2. **Create GitHub project** with tasks 3. **Set up test environment** for API backend 4. **Begin Phase 1:** Backend API creation --- ## Questions to Resolve 1. **API Versioning:** Start with `/api/v1` or wait until v2 needed? 2. **Real-time Updates:** How to handle without Convex subscriptions? (WebSocket, SSE, polling?) 3. **Batch Operations:** Should API support batching multiple operations? 4. **GraphQL:** Consider GraphQL instead of REST? 5. **API Gateway:** Use Cloudflare API Gateway for additional features? --- ## Conclusion This separation transforms ONE from a tightly-coupled monolith into a flexible, API-first platform that can: - Serve multiple frontends from one backend - Support web, mobile, desktop, CLI - Enable multi-tenancy with API key isolation - Version API independently - Scale frontend and backend separately The key is **gradual migration** with the ability to rollback at any point. By following this plan step-by-step, we minimize risk while achieving maximum architectural flexibility.