From 15f0b1c97aa17636e3cf3f85b2a8a84981618850 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 3 Apr 2026 00:31:34 +0000 Subject: [PATCH] fix(31): revise plans based on checker feedback --- .../31-01-PLAN.md | 33 +++-- .../31-02-PLAN.md | 98 ++++++++++++--- .../31-03-PLAN.md | 114 +++++++++--------- .../31-04-PLAN.md | 85 +++++-------- 4 files changed, 189 insertions(+), 141 deletions(-) diff --git a/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md b/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md index 1d1379f0..f050c90b 100644 --- a/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md +++ b/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md @@ -17,7 +17,8 @@ must_haves: - "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" + - "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" @@ -39,7 +40,7 @@ must_haves: pattern: "secretService.*getByName|create|rotate" - from: "server/src/services/puter-proxy.ts" to: "server/src/services/costs.ts" - via: "costService.createEvent with provider=puter" + 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" @@ -87,7 +88,7 @@ From server/src/services/costs.ts: export function costService(db: Db, budgetHooks?) { return { createEvent: async (companyId: string, data: { - agentId: string; + agentId: string; // REQUIRED — FK to agents table, NOT NULL in schema provider: string; biller?: string; billingType?: string; @@ -100,6 +101,9 @@ export function costService(db: Db, budgetHooks?) { }) => 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: @@ -149,9 +153,11 @@ api.use(costRoutes(db)); - 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 - - Test 7: POST /api/puter-proxy/token stores token and returns 200 - - Test 8: POST /api/puter-proxy/chat sets SSE headers and streams response 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:** @@ -163,12 +169,13 @@ api.use(costRoutes(db)); - `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 record cost event non-blocking: `costService.createEvent(companyId, { agentId, provider: "puter", biller: "puter", billingType: "subscription_included", model, inputTokens, outputTokens, costCents: 0, occurredAt: new Date() }).catch(() => {})`. + - 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:** @@ -176,11 +183,11 @@ api.use(costRoutes(db)); - `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. `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()`. + - `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 8 behaviors listed above. For SSE streaming test, create a mock ReadableStream that yields pre-formatted SSE chunks. + 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. @@ -194,15 +201,16 @@ api.use(costRoutes(db)); - 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 8 tests pass + - All 10 tests pass - puterProxyService handles token storage (create/rotate idempotent), chatStream relays to Puter with streaming and cost tracking, puterProxyRoutes exposes POST /puter-proxy/token and POST /puter-proxy/chat with board auth, all tests green + 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 @@ -242,7 +250,8 @@ api.use(costRoutes(db)); -- Puter proxy service exists with token storage, streaming chat, and cost tracking +- 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 diff --git a/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md b/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md index ad5bb8f6..d0fec044 100644 --- a/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md +++ b/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md @@ -7,6 +7,7 @@ depends_on: [] files_modified: - server/src/services/google-oauth.ts - server/src/routes/google-oauth.ts + - server/src/__tests__/31-google-oauth.test.ts - server/src/app.ts autonomous: true requirements: [CLOUD-03, CLOUD-05] @@ -15,15 +16,18 @@ must_haves: truths: - "Server can generate a Google OAuth PKCE authorization URL with correct client_id, scopes, code_challenge" - "Server can exchange an authorization code for access/refresh tokens via Google's token endpoint" - - "Google OAuth tokens are stored server-side via secretService as google_gemini_oauth_token" - - "POST /api/puter-proxy/token supports storing arbitrary provider tokens (reused for API key entry)" + - "Google OAuth tokens are stored temporarily by stateId, then permanently via secretService after claim" + - "POST /api/oauth/google/claim moves tokens from temp storage to secretService under a real companyId" + - "POST /api/api-keys/store supports storing arbitrary provider API keys" artifacts: - path: "server/src/services/google-oauth.ts" - provides: "googleOAuthService with generateAuthUrl, exchangeCode, storeTokens methods" + provides: "googleOAuthService with generateAuthUrl, exchangeCode, storeTokens, resolveTokens methods" exports: ["googleOAuthService"] - path: "server/src/routes/google-oauth.ts" - provides: "Routes for GET /api/oauth/google/authorize and GET /api/oauth/google/callback" + provides: "Routes for POST /api/oauth/google/authorize, GET /api/oauth/google/callback, POST /api/oauth/google/claim, POST /api/api-keys/store" exports: ["googleOAuthRoutes"] + - path: "server/src/__tests__/31-google-oauth.test.ts" + provides: "Unit tests for PKCE generation, token exchange, claim flow, API key storage" key_links: - from: "server/src/services/google-oauth.ts" to: "server/src/services/secrets.ts" @@ -33,13 +37,17 @@ must_haves: to: "server/src/services/google-oauth.ts" via: "import googleOAuthService" pattern: "googleOAuthService" + - from: "server/src/routes/google-oauth.ts" + to: "pendingTokens Map" + via: "callback stores by stateId, claim retrieves by stateId" + pattern: "pendingTokens" --- -Build the Google OAuth PKCE service and routes for Gemini free tier access, plus ensure the token storage endpoint supports API key entry for subscription providers. +Build the Google OAuth PKCE service and routes for Gemini free tier access, plus API key storage route. The OAuth callback stores tokens temporarily by stateId (no companyId needed at callback time), and a separate /claim endpoint links them to a real companyId after company creation. Purpose: CLOUD-03 (Google OAuth for Gemini) and CLOUD-05 (API key entry). The OAuth callback needs a server-side route to exchange the code for tokens. -Output: googleOAuthService, googleOAuthRoutes, app.ts wiring +Output: googleOAuthService, googleOAuthRoutes, unit tests, app.ts wiring @@ -114,7 +122,7 @@ api.use(secretRoutes(db)); // line 157 - `exchangeCode(code: string, redirectUri: string, verifier: string)`: POST to `GOOGLE_TOKEN_URL` with `grant_type=authorization_code`, `client_id=GEMINI_CLIENT_ID`, `code`, `redirect_uri=redirectUri`, `code_verifier=verifier`. Parse JSON response. Return `{ accessToken, refreshToken, expiresIn }`. - - `storeTokens(companyId: string, tokens: { accessToken: string; refreshToken?: string })`: Store JSON.stringify(tokens) via secretService upsert pattern (getByName → exists ? rotate : create) under name `google_gemini_oauth_token`, provider `local_encrypted`. + - `storeTokens(companyId: string, tokens: { accessToken: string; refreshToken?: string })`: Store JSON.stringify(tokens) via secretService upsert pattern (getByName -> exists ? rotate : create) under name `google_gemini_oauth_token`, provider `local_encrypted`. - `resolveTokens(companyId: string)`: Get secret by name, resolve value, JSON.parse, return `{ accessToken, refreshToken }`. @@ -136,7 +144,7 @@ api.use(secretRoutes(db)); // line 157 - Task 2: googleOAuthRoutes + API key storage route + mount in app.ts + Task 2: googleOAuthRoutes (pendingTokens pattern) + API key route + mount in app.ts server/src/routes/google-oauth.ts, server/src/app.ts @@ -151,13 +159,19 @@ api.use(secretRoutes(db)); // line 157 Export `googleOAuthRoutes(db: Db): Router`. - In-memory Map for PKCE state: `const pendingPkce = new Map()`. Clean up entries older than 10 minutes on each request. + **Two in-memory Maps:** + - `const pendingPkce = new Map()` — stores PKCE verifier keyed by state UUID. NO companyId stored here (companyId does not exist at authorize time). + - `const pendingTokens = new Map()` — stores exchanged tokens keyed by stateId after callback completes. + + Clean up entries older than 10 minutes from both maps on each request. Routes: - - `POST /oauth/google/authorize`: `assertBoard(req)`. Extract `companyId` from `req.body`. `assertCompanyAccess(req, companyId)`. Generate random `state` via `crypto.randomUUID()`. Construct `redirectUri` from `req.protocol + "://" + req.get("host") + "/api/oauth/google/callback"`. Call `googleOAuthService(db).generateAuthUrl(redirectUri, state)`. Store `{ verifier, companyId, createdAt: Date.now() }` in `pendingPkce` keyed by `state`. Return `res.json({ url })`. + - `POST /oauth/google/authorize`: `assertBoard(req)`. Generate random `state` via `crypto.randomUUID()`. Construct `redirectUri` from `req.protocol + "://" + req.get("host") + "/api/oauth/google/callback"`. Call `googleOAuthService(db).generateAuthUrl(redirectUri, state)`. Store `{ verifier, createdAt: Date.now() }` in `pendingPkce` keyed by `state`. Return `res.json({ url, stateId: state })`. Note: NO companyId is needed or accepted here — the company does not exist yet during onboarding step 3. - - `GET /oauth/google/callback`: Extract `code` and `state` from `req.query`. Look up `pendingPkce.get(state)`. If not found, return `res.status(400).send("Invalid or expired OAuth state")`. Delete from map. Construct `redirectUri` same as above. Call `googleOAuthService(db).exchangeCode(code, redirectUri, verifier)`. Call `googleOAuthService(db).storeTokens(companyId, { accessToken, refreshToken })`. Redirect to a UI success page: `res.redirect("/?google_oauth=success")`. Wrap in try/catch — on error, redirect to `/?google_oauth=error`. + - `GET /oauth/google/callback`: Extract `code` and `state` from `req.query`. Look up `pendingPkce.get(state)`. If not found, return `res.status(400).send("Invalid or expired OAuth state")`. Delete from `pendingPkce`. Construct `redirectUri` same as above. Call `googleOAuthService(db).exchangeCode(code, redirectUri, verifier)`. Store tokens temporarily: `pendingTokens.set(state, { accessToken, refreshToken, createdAt: Date.now() })`. Redirect to `/?google_oauth=success&state=${state}`. Wrap in try/catch — on error, redirect to `/?google_oauth=error`. **IMPORTANT:** This route does NOT store tokens in secretService — it only parks them in memory. The /claim endpoint (below) does the permanent storage. + + - `POST /oauth/google/claim`: `assertBoard(req)`. Extract `stateId` and `companyId` from `req.body`. Validate both are non-empty strings. `assertCompanyAccess(req, companyId)`. Look up `pendingTokens.get(stateId)`. If not found, return `res.status(404).json({ error: "OAuth session expired or not found" })`. Call `googleOAuthService(db).storeTokens(companyId, { accessToken, refreshToken })`. Delete from `pendingTokens`. Return `res.json({ ok: true })`. Clean up entries older than 10 minutes. - `POST /api-keys/store`: `assertBoard(req)`. Extract `companyId`, `provider` (string: "openai" | "anthropic" | "groq"), `apiKey` from `req.body`. Validate all present. `assertCompanyAccess(req, companyId)`. Store via secretService upsert pattern with name `${provider}_api_key` (e.g., "openai_api_key"), provider "local_encrypted". Return `res.json({ ok: true })`. @@ -165,7 +179,7 @@ api.use(secretRoutes(db)); // line 157 Add import: `import { googleOAuthRoutes } from "./routes/google-oauth.js";` - The OAuth callback (`GET /oauth/google/callback`) needs to be accessible without boardMutationGuard blocking the GET (GETs pass through boardMutationGuard since it only blocks mutations). The authorize and api-keys/store endpoints need board auth. Mount inside the `api` Router: + The OAuth callback (`GET /oauth/google/callback`) needs to be accessible without boardMutationGuard blocking the GET (GETs pass through boardMutationGuard since it only blocks mutations). The authorize, claim, and api-keys/store endpoints need board auth. Mount inside the `api` Router: ```typescript api.use(googleOAuthRoutes(db)); @@ -174,34 +188,82 @@ api.use(secretRoutes(db)); // line 157 Place after `puterProxyRoutes(db)` (or after `costRoutes(db)` if plan 01 hasn't run yet — the executor should place it near the other new routes). - cd /opt/nexus && grep -n "googleOAuthRoutes" server/src/app.ts && grep -c "oauth/google/authorize\|oauth/google/callback\|api-keys/store" server/src/routes/google-oauth.ts + cd /opt/nexus && grep -n "googleOAuthRoutes" server/src/app.ts && grep -c "oauth/google/authorize\|oauth/google/callback\|oauth/google/claim\|api-keys/store" server/src/routes/google-oauth.ts - grep -q "googleOAuthRoutes" server/src/routes/google-oauth.ts - grep -q "oauth/google/authorize" server/src/routes/google-oauth.ts - grep -q "oauth/google/callback" server/src/routes/google-oauth.ts - - grep -q "api-keys/store" server/src/routes/google-oauth.ts + - grep -q "oauth/google/claim" server/src/routes/google-oauth.ts + - grep -q "pendingTokens" server/src/routes/google-oauth.ts - grep -q "pendingPkce" server/src/routes/google-oauth.ts + - grep -q "api-keys/store" server/src/routes/google-oauth.ts - grep -q "assertBoard" server/src/routes/google-oauth.ts + - grep -q "stateId" server/src/routes/google-oauth.ts - grep -q "import.*googleOAuthRoutes" server/src/app.ts - grep -q "api.use(googleOAuthRoutes" server/src/app.ts + - NO grep match for "companyId" in pendingPkce — companyId must NOT be stored at authorize time - Google OAuth PKCE routes handle authorize + callback flow, API key storage route accepts provider/key pairs, both mounted in app.ts with board auth + Google OAuth routes use the pendingTokens pattern: authorize stores PKCE verifier only (no companyId), callback exchanges code and parks tokens by stateId, claim endpoint links tokens to companyId after company creation. API key storage route supports OpenAI/Anthropic/Groq key entry. All mounted in app.ts. + + + + Task 3: Unit tests for Google OAuth service and routes + server/src/__tests__/31-google-oauth.test.ts + + server/src/services/google-oauth.ts, + server/src/routes/google-oauth.ts, + server/src/__tests__/31-puter-proxy.test.ts + + + - Test 1: generatePkce produces verifier (43 chars base64url) and challenge (43 chars base64url) + - Test 2: generateAuthUrl returns URL containing client_id, code_challenge, code_challenge_method=S256, state + - Test 3: exchangeCode POSTs to Google token endpoint with correct body (grant_type, code_verifier) + - Test 4: storeTokens creates a new secret when none exists (name: "google_gemini_oauth_token") + - Test 5: storeTokens rotates when secret already exists + - Test 6: POST /oauth/google/authorize returns { url, stateId } and stores verifier in pendingPkce (no companyId) + - Test 7: GET /oauth/google/callback with valid state exchanges code, stores tokens in pendingTokens, redirects to /?google_oauth=success + - Test 8: GET /oauth/google/callback with invalid state returns 400 + - Test 9: POST /oauth/google/claim with valid stateId moves tokens to secretService and returns { ok: true } + - Test 10: POST /oauth/google/claim with expired/missing stateId returns 404 + - Test 11: POST /api-keys/store stores key via secretService upsert pattern + + + Create `server/src/__tests__/31-google-oauth.test.ts` using vitest. + + Mock `fetch` globally for Google token endpoint calls. Create a mock db object that stubs secretService behavior (getByName, create, rotate, resolveSecretValue). + + For service tests: import `googleOAuthService` directly and test each method. + + For route tests: use supertest with a minimal Express app that mounts `googleOAuthRoutes(mockDb)`. Test the full authorize -> callback -> claim flow in sequence (one test that exercises all three endpoints). Also test error cases (invalid state, expired state). + + Import patterns: `import { googleOAuthService } from "../services/google-oauth.js"` and `import { googleOAuthRoutes } from "../routes/google-oauth.js"`. + + Follow the same test file structure as `31-puter-proxy.test.ts` (from Task 1 of Plan 01). + + + cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-google-oauth.test.ts + + All 11 Google OAuth tests pass, covering PKCE generation, token exchange, the pendingTokens claim flow, and API key storage +- `cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-google-oauth.test.ts` — all tests pass - `cd /opt/nexus && pnpm --filter @paperclipai/server exec tsc --noEmit` — no TypeScript errors - grep confirms all routes and services exist with correct exports -- Google OAuth routes handle full PKCE flow (authorize URL generation, callback code exchange, token storage) -- API key storage route supports OpenAI/Anthropic/Groq key entry +- Google OAuth callback uses pendingTokens pattern (no companyId at callback time) +- Claim endpoint properly links tokens to companyId - googleOAuthService generates PKCE auth URLs, exchanges codes, stores tokens -- Routes exist for POST /oauth/google/authorize, GET /oauth/google/callback, POST /api-keys/store +- Routes exist for POST /oauth/google/authorize, GET /oauth/google/callback, POST /oauth/google/claim, POST /api-keys/store +- Callback stores tokens temporarily by stateId (NOT by companyId) +- Claim endpoint moves tokens from temp to secretService with real companyId - All routes mounted in app.ts +- All unit tests pass - Server compiles without TypeScript errors diff --git a/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md b/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md index 7fedf0e2..827e57bf 100644 --- a/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md +++ b/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md @@ -17,47 +17,57 @@ requirements: [CLOUD-01, CLOUD-03, CLOUD-04, CLOUD-05] must_haves: truths: - "User sees a Provider Selection step (Step 3 of 4) in the onboarding wizard with Puter, Google, and API Key options" - - "Clicking 'Continue with Puter' triggers Puter auth popup, stores token server-side, and advances wizard" + - "Clicking 'Continue with Puter' triggers Puter auth popup, stores token in React state for later server submission" - "Google option shows policy-risk warning before enabling the Sign in button (3-second gate)" + - "Google OAuth opens popup, callback stores tokens by stateId, stateId captured in React state for later claim" - "API key option shows inline form with provider dropdown and key input" - "Skip for now button is always visible and advances to root directory step" - "Pre-installed tools (Hermes, Claude Code, OpenClaw) show detected badges via adapter probe" - "Step indicator shows 'Step N of 4' (updated from 3)" + - "handleSubmit posts collected credentials to server AFTER company creation (Puter token, Google claim, API key)" artifacts: - path: "ui/src/components/onboarding/ProviderSelectionStep.tsx" provides: "Provider selection UI with three option cards and skip button" exports: ["ProviderSelectionStep"] - path: "ui/src/components/onboarding/PuterAuthButton.tsx" - provides: "Puter auth button that loads CDN script, calls signIn, posts token to server" + provides: "Puter auth button that loads CDN script, calls signIn, captures token in state" exports: ["PuterAuthButton"] - path: "ui/src/components/onboarding/GoogleOAuthButton.tsx" - provides: "Google OAuth button with 3-second risk warning gate" + provides: "Google OAuth button with 3-second risk warning gate, opens popup, captures stateId" exports: ["GoogleOAuthButton"] - path: "ui/src/components/onboarding/ApiKeyEntryForm.tsx" provides: "API key entry form with provider dropdown" exports: ["ApiKeyEntryForm"] - path: "ui/src/api/puter-proxy.ts" - provides: "API client for puter-proxy and oauth endpoints" + provides: "API client for puter-proxy, oauth, and api-keys endpoints" exports: ["puterProxyApi"] - path: "ui/src/components/NexusOnboardingWizard.tsx" provides: "4-step wizard with provider selection step inserted" key_links: - from: "ui/src/components/onboarding/PuterAuthButton.tsx" to: "POST /api/puter-proxy/token" - via: "fetch call to store Puter token server-side" + via: "puterProxyApi.storeToken called in wizard handleSubmit after company creation" pattern: "puter-proxy/token" - from: "ui/src/components/onboarding/GoogleOAuthButton.tsx" to: "POST /api/oauth/google/authorize" - via: "fetch call to get auth URL, then window.open" + via: "fetch call to get auth URL + stateId, then window.open" pattern: "oauth/google/authorize" + - from: "ui/src/components/NexusOnboardingWizard.tsx" + to: "POST /api/oauth/google/claim" + via: "claim call in handleSubmit with stateId + companyId after company creation" + pattern: "oauth/google/claim" - from: "ui/src/components/onboarding/ApiKeyEntryForm.tsx" to: "POST /api/api-keys/store" - via: "fetch call to store API key" + via: "puterProxyApi.storeApiKey called in wizard handleSubmit after company creation" pattern: "api-keys/store" - from: "ui/src/components/NexusOnboardingWizard.tsx" to: "ui/src/components/onboarding/ProviderSelectionStep.tsx" via: "import and render in step 3" pattern: "ProviderSelectionStep" + - from: "ui/src/components/NexusOnboardingWizard.tsx" + to: "GET /api/adapters/:type/probe" + via: "probeAdapter calls for hermes_local, claude_local, openclaw_gateway on mount" + pattern: "probeAdapter|adapters.*probe" --- @@ -113,6 +123,13 @@ Google card label: "Google -- Gemini free tier" Google risk warning body: "Google has suspended accounts that used third-party apps with Gemini credentials. This may affect your Gmail and Workspace access. Use a Google AI Studio API key instead if you want to avoid this risk." API key card label: "API key -- subscription provider" ``` + +From Plan 02 routes (pendingTokens pattern): +```typescript +// POST /oauth/google/authorize returns { url, stateId } +// GET /oauth/google/callback stores tokens by stateId, redirects to /?google_oauth=success&state={stateId} +// POST /oauth/google/claim { stateId, companyId } -> moves tokens to secretService +``` @@ -138,14 +155,15 @@ API key card label: "API key -- subscription provider" Import `api` from `./client`. Export `puterProxyApi` with: - `storeToken: (companyId: string, token: string) => api.post("/puter-proxy/token", { companyId, token })` - - `getAuthUrl: (companyId: string) => api.post<{ url: string }>("/oauth/google/authorize", { companyId })` + - `getAuthUrl: () => api.post<{ url: string; stateId: string }>("/oauth/google/authorize", {})` — no companyId needed + - `claimGoogleTokens: (stateId: string, companyId: string) => api.post("/oauth/google/claim", { stateId, companyId })` - `storeApiKey: (companyId: string, provider: string, apiKey: string) => api.post("/api-keys/store", { companyId, provider, apiKey })` **Create ui/src/components/onboarding/PuterAuthButton.tsx:** - Props: `{ companyId?: string; onSuccess: () => void; onError: (msg: string) => void }`. + Props: `{ onSuccess: (token: string) => void; onError: (msg: string) => void }`. - State: `loading` boolean. + State: `loading` boolean, `connected` boolean. `loadScript` helper: check if `window.puter` exists; if not, create a `