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
Markdown
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.