nexus/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md

14 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
31-puter.js-zero-config-cloud 01 execute 1
server/src/services/puter-proxy.ts
server/src/routes/puter-proxy.ts
server/src/__tests__/31-puter-proxy.test.ts
server/src/app.ts
true
CLOUD-01
CLOUD-02
truths artifacts key_links
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
path provides exports
server/src/services/puter-proxy.ts puterProxyService with resolveToken, chatStream, storeToken methods
puterProxyService
path provides exports
server/src/routes/puter-proxy.ts Express routes for POST /api/puter-proxy/chat and POST /api/puter-proxy/token
puterProxyRoutes
path provides
server/src/__tests__/31-puter-proxy.test.ts Unit tests for token storage, proxy streaming, cost recording
from to via pattern
server/src/routes/puter-proxy.ts server/src/services/puter-proxy.ts import puterProxyService puterProxyService
from to via pattern
server/src/services/puter-proxy.ts server/src/services/secrets.ts secretService getByName/create/rotate secretService.*getByName|create|rotate
from to via pattern
server/src/services/puter-proxy.ts server/src/services/costs.ts costService.createEvent with provider=puter (only when agentId present) createEvent.*puter
from to via pattern
server/src/app.ts server/src/routes/puter-proxy.ts api.use(puterProxyRoutes(db)) 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

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

@.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:

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:

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:

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):

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):

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

<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>
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-01-SUMMARY.md`