14 KiB
14 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 31-puter.js-zero-config-cloud | 01 | execute | 1 |
|
true |
|
|
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.mdFrom 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>