--- phase: 31-puter.js-zero-config-cloud plan: "01" type: execute wave: 1 depends_on: [] files_modified: - server/src/services/puter-proxy.ts - server/src/routes/puter-proxy.ts - server/src/__tests__/31-puter-proxy.test.ts - server/src/app.ts autonomous: true requirements: [CLOUD-01, CLOUD-02] must_haves: truths: - "Puter auth token can be stored server-side via secretService and retrieved for API calls" - "POST /api/puter-proxy/chat relays messages to Puter OpenAI-compat endpoint with correct Bearer header" - "POST /api/puter-proxy/chat streams SSE tokens back to the client" - "Cost events are recorded after every Puter chat completion with provider=puter and billingType=subscription_included when agentId is provided" - "POST /api/puter-proxy/chat skips cost recording when agentId is absent (pre-agent-creation calls)" - "POST /api/puter-proxy/token stores (or rotates) the Puter auth token idempotently" artifacts: - path: "server/src/services/puter-proxy.ts" provides: "puterProxyService with resolveToken, chatStream, storeToken methods" exports: ["puterProxyService"] - path: "server/src/routes/puter-proxy.ts" provides: "Express routes for POST /api/puter-proxy/chat and POST /api/puter-proxy/token" exports: ["puterProxyRoutes"] - path: "server/src/__tests__/31-puter-proxy.test.ts" provides: "Unit tests for token storage, proxy streaming, cost recording" key_links: - from: "server/src/routes/puter-proxy.ts" to: "server/src/services/puter-proxy.ts" via: "import puterProxyService" pattern: "puterProxyService" - from: "server/src/services/puter-proxy.ts" to: "server/src/services/secrets.ts" via: "secretService getByName/create/rotate" pattern: "secretService.*getByName|create|rotate" - from: "server/src/services/puter-proxy.ts" to: "server/src/services/costs.ts" via: "costService.createEvent with provider=puter (only when agentId present)" pattern: "createEvent.*puter" - from: "server/src/app.ts" to: "server/src/routes/puter-proxy.ts" via: "api.use(puterProxyRoutes(db))" pattern: "puterProxyRoutes" --- Build the server-side Puter proxy service and routes that relay AI chat requests to Puter's OpenAI-compatible endpoint, store/rotate auth tokens via secretService, stream SSE responses, and record cost events. Purpose: This is the core backend for CLOUD-01 (zero-config AI via Puter) and CLOUD-02 (server-proxied adapter with cost tracking). Without this, the UI has nothing to call. Output: puterProxyService, puterProxyRoutes, unit tests, app.ts wiring @/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md @/home/mikkel/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md From server/src/services/secrets.ts: ```typescript export function secretService(db: Db) { // ... return { getByName: async (companyId: string, name: string) => row | null, create: async (companyId: string, input: { name: string; provider: SecretProvider; value: string; description?: string | null }, actor?) => secret, rotate: async (secretId: string, input: { value: string }, actor?) => secret, resolveSecretValue: async (companyId: string, secretId: string, version: number | "latest") => string, // ... }; } ``` From server/src/services/costs.ts: ```typescript export function costService(db: Db, budgetHooks?) { return { createEvent: async (companyId: string, data: { agentId: string; // REQUIRED — FK to agents table, NOT NULL in schema provider: string; biller?: string; billingType?: string; model: string; inputTokens: number; outputTokens: number; costCents: number; occurredAt: Date; cachedInputTokens?: number; }) => event, }; } // NOTE: agentId is NOT NULL in the cost_events schema and createEvent // validates the agent exists. Cost recording MUST be skipped when no // agentId is available (e.g., during onboarding before agent creation). ``` From server/src/routes/authz.ts: ```typescript export function assertBoard(req: Request): void; export function assertCompanyAccess(req: Request, companyId: string): void; ``` From server/src/routes/chat.ts (SSE pattern, lines 96-102): ```typescript res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); res.write(":ok\n\n"); ``` From server/src/app.ts (route mounting, lines 155-158): ```typescript api.use(secretRoutes(db)); api.use(costRoutes(db)); // ... new routes go here, inside the api Router (after boardMutationGuard) ``` Task 1: puterProxyService + puterProxyRoutes + unit tests server/src/services/puter-proxy.ts, server/src/routes/puter-proxy.ts, server/src/__tests__/31-puter-proxy.test.ts server/src/services/secrets.ts, server/src/services/costs.ts, server/src/routes/chat.ts, server/src/routes/secrets.ts, server/src/routes/authz.ts - Test 1: storeToken creates a new secret via secretService when none exists (name: "puter_auth_token", provider: "local_encrypted") - Test 2: storeToken rotates an existing secret when "puter_auth_token" already exists (calls rotate, not create) - Test 3: resolveToken retrieves the stored token value via secretService.resolveSecretValue - Test 4: chatStream sends POST to https://api.puter.com/puterai/openai/v1/chat/completions with Authorization Bearer header, stream: true, stream_options: { include_usage: true } - Test 5: chatStream yields content strings from SSE data chunks - Test 6: chatStream records a cost event with provider="puter", billingType="subscription_included", costCents=0 after stream completes (when agentId is provided) - Test 7: chatStream skips cost recording when agentId is null/undefined (no error thrown, stream still works) - Test 8: POST /api/puter-proxy/token stores token and returns 200 - Test 9: POST /api/puter-proxy/chat sets SSE headers and streams response chunks - Test 10: POST /api/puter-proxy/chat works without agentId in request body (agentId is optional) **Create server/src/services/puter-proxy.ts:** Factory function `puterProxyService(db: Db)` that uses `secretService(db)` and `costService(db)` internally. Returns: - `storeToken(companyId: string, token: string)`: Call `secretService.getByName(companyId, "puter_auth_token")`. If exists, call `secretService.rotate(existing.id, { value: token })`. If not, call `secretService.create(companyId, { name: "puter_auth_token", provider: "local_encrypted", value: token, description: "Puter.com auth token for AI proxy" })`. Return the secret record. - `resolveToken(companyId: string)`: Call `secretService.getByName(companyId, "puter_auth_token")`. Throw `unprocessable("Puter auth token not configured")` if null. Call `secretService.resolveSecretValue(companyId, secret.id, "latest")` and return string. - `async *chatStream(companyId, agentId, messages, model, signal)`: AsyncGenerator. - **agentId parameter type: `string | null | undefined`** — it is OPTIONAL because the proxy may be called during onboarding before any agent exists. - Constants: `PUTER_BASE_URL = "https://api.puter.com/puterai/openai/v1"`, `PUTER_DEFAULT_MODEL = "claude-3-5-haiku-20241022"`. - Call `resolveToken(companyId)` to get bearer token. - `fetch(`${PUTER_BASE_URL}/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: model ?? PUTER_DEFAULT_MODEL, messages, stream: true, stream_options: { include_usage: true } }), signal })`. - If `!response.ok || !response.body`, read body text and throw `Error(`Puter API error ${response.status}: ${text}`)`. - Parse SSE: read from `response.body` using `getReader()`, split on `\n`, look for `data: ` prefix. Skip `[DONE]`. Parse JSON. Yield `chunk.choices?.[0]?.delta?.content`. Accumulate `usage` from chunks that have it. - In `finally` block: `reader.releaseLock()`. Then **only if agentId is truthy**, record cost event non-blocking: `if (agentId) { costService.createEvent(companyId, { agentId, provider: "puter", biller: "puter", billingType: "subscription_included", model, inputTokens, outputTokens, costCents: 0, occurredAt: new Date() }).catch(() => {}); }`. When agentId is null/undefined, skip cost recording entirely — the cost_events table requires a non-null agentId FK. **Create server/src/routes/puter-proxy.ts:** Export `puterProxyRoutes(db: Db): Router`. - `POST /puter-proxy/token`: `assertBoard(req)`. Extract `companyId` and `token` from `req.body`. Validate both are non-empty strings. `assertCompanyAccess(req, companyId)`. Call `puterProxyService(db).storeToken(companyId, token)`. Return `res.json({ ok: true })`. - `POST /puter-proxy/chat`: `assertBoard(req)`. Extract `companyId`, `agentId`, `messages`, `model` from `req.body`. Validate `companyId` is string, `messages` is array with at least one entry. **agentId is OPTIONAL** — do NOT validate its presence; pass it through as-is (string or undefined) to `chatStream`. `assertCompanyAccess(req, companyId)`. Set SSE headers (same pattern as chat.ts lines 96-102). Create AbortController, wire `req.on("close")`. Iterate `puterProxyService(db).chatStream(...)` yielding `data: ${JSON.stringify({ token: chunk })}\n\n`. On complete write `data: ${JSON.stringify({ done: true })}\n\n`. On error write `data: ${JSON.stringify({ error: "Puter stream error" })}\n\n`. Always `res.end()`. **Create server/src/__tests__/31-puter-proxy.test.ts:** Use vitest. Mock `fetch` globally (vi.stubGlobal). Create a mock db object that stubs secretService and costService behavior. Test all 10 behaviors listed above. For SSE streaming test, create a mock ReadableStream that yields pre-formatted SSE chunks. For Test 7 (no agentId), verify that costService.createEvent is NOT called when agentId is null. For Test 10, verify the route accepts a request body without agentId and responds with SSE stream. Import patterns: `import { puterProxyService } from "../services/puter-proxy.js"` and `import { puterProxyRoutes } from "../routes/puter-proxy.js"`. For route tests, use supertest with a minimal Express app. cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts - grep -q "puterProxyService" server/src/services/puter-proxy.ts - grep -q "puter_auth_token" server/src/services/puter-proxy.ts - grep -q "api.puter.com/puterai/openai/v1" server/src/services/puter-proxy.ts - grep -q "subscription_included" server/src/services/puter-proxy.ts - grep -q "costCents: 0" server/src/services/puter-proxy.ts - grep -q "stream_options" server/src/services/puter-proxy.ts - grep -q "if (agentId)" server/src/services/puter-proxy.ts (cost recording guard) - grep -q "puterProxyRoutes" server/src/routes/puter-proxy.ts - grep -q "puter-proxy/token" server/src/routes/puter-proxy.ts - grep -q "puter-proxy/chat" server/src/routes/puter-proxy.ts - grep -q "text/event-stream" server/src/routes/puter-proxy.ts - grep -q "assertBoard" server/src/routes/puter-proxy.ts - All 10 tests pass puterProxyService handles token storage (create/rotate idempotent), chatStream relays to Puter with streaming, cost tracking fires only when agentId is present (skipped when null — no FK violation), puterProxyRoutes exposes POST /puter-proxy/token and POST /puter-proxy/chat with board auth, all tests green Task 2: Mount puterProxyRoutes in app.ts server/src/app.ts server/src/app.ts Add import at top of server/src/app.ts: ```typescript import { puterProxyRoutes } from "./routes/puter-proxy.js"; ``` Mount inside the `api` Router block (after `boardMutationGuard()`, near the other route mounts around line 158), add: ```typescript api.use(puterProxyRoutes(db)); ``` Place it after `api.use(costRoutes(db));` (line 158) to keep related routes together. This ensures the route is protected by boardMutationGuard (correct — Puter proxy requires board auth). cd /opt/nexus && grep -n "puterProxyRoutes" server/src/app.ts - grep -q "import.*puterProxyRoutes.*from.*routes/puter-proxy" server/src/app.ts - grep -q "api.use(puterProxyRoutes(db))" server/src/app.ts puterProxyRoutes mounted inside api Router with board auth protection; server compiles without errors - `cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts` — all tests pass - `cd /opt/nexus && pnpm --filter @paperclipai/server exec tsc --noEmit` — no TypeScript errors - grep confirms route is mounted in app.ts - Puter proxy service exists with token storage, streaming chat, and conditional cost tracking - agentId is optional in the proxy route — cost recording skipped when absent - Routes exist for POST /puter-proxy/token and POST /puter-proxy/chat - Routes mounted in app.ts behind boardMutationGuard - All unit tests pass After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-01-SUMMARY.md`