---
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"
---

<objective>
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
</objective>

<execution_context>
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
@/home/mikkel/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

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)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: puterProxyService + puterProxyRoutes + unit tests</name>
  <files>
    server/src/services/puter-proxy.ts,
    server/src/routes/puter-proxy.ts,
    server/src/__tests__/31-puter-proxy.test.ts
  </files>
  <read_first>
    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
  </read_first>
  <behavior>
    - 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)
  </behavior>
  <action>
    **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<string>.
      - **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.
  </action>
  <verify>
    <automated>cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>
    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
  </done>
</task>

<task type="auto">
  <name>Task 2: Mount puterProxyRoutes in app.ts</name>
  <files>server/src/app.ts</files>
  <read_first>server/src/app.ts</read_first>
  <action>
    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).
  </action>
  <verify>
    <automated>cd /opt/nexus && grep -n "puterProxyRoutes" server/src/app.ts</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "import.*puterProxyRoutes.*from.*routes/puter-proxy" server/src/app.ts
    - grep -q "api.use(puterProxyRoutes(db))" server/src/app.ts
  </acceptance_criteria>
  <done>puterProxyRoutes mounted inside api Router with board auth protection; server compiles without errors</done>
</task>

</tasks>

<verification>
- `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
</verification>

<success_criteria>
- 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
</success_criteria>

<output>
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-01-SUMMARY.md`
</output>
