fix(31): revise plans based on checker feedback

This commit is contained in:
Nexus Dev 2026-04-03 00:31:34 +00:00
parent 7bc9be40ee
commit 15f0b1c97a
4 changed files with 189 additions and 141 deletions

View file

@ -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)
</behavior>
<action>
**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<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 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.
</action>
@ -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
</acceptance_criteria>
<done>
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
</done>
</task>
@ -242,7 +250,8 @@ api.use(costRoutes(db));
</verification>
<success_criteria>
- 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

View file

@ -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"
---
<objective>
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
</objective>
<execution_context>
@ -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>
<task type="auto">
<name>Task 2: googleOAuthRoutes + API key storage route + mount in app.ts</name>
<name>Task 2: googleOAuthRoutes (pendingTokens pattern) + API key route + mount in app.ts</name>
<files>
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<string, { verifier: string; companyId: string; createdAt: number }>()`. Clean up entries older than 10 minutes on each request.
**Two in-memory Maps:**
- `const pendingPkce = new Map<string, { verifier: string; createdAt: number }>()` — stores PKCE verifier keyed by state UUID. NO companyId stored here (companyId does not exist at authorize time).
- `const pendingTokens = new Map<string, { accessToken: string; refreshToken?: string; createdAt: number }>()` — 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).
</action>
<verify>
<automated>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</automated>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Google OAuth PKCE routes handle authorize + callback flow, API key storage route accepts provider/key pairs, both mounted in app.ts with board auth</done>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Unit tests for Google OAuth service and routes</name>
<files>server/src/__tests__/31-google-oauth.test.ts</files>
<read_first>
server/src/services/google-oauth.ts,
server/src/routes/google-oauth.ts,
server/src/__tests__/31-puter-proxy.test.ts
</read_first>
<behavior>
- 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
</behavior>
<action>
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).
</action>
<verify>
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-google-oauth.test.ts</automated>
</verify>
<done>All 11 Google OAuth tests pass, covering PKCE generation, token exchange, the pendingTokens claim flow, and API key storage</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>

View file

@ -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"
---
<objective>
@ -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
```
</interfaces>
</context>
@ -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 `<script>` tag with `src="https://js.puter.com/v2/"`, append to `<head>`, wait for `load` event. Return a promise.
@ -154,63 +172,49 @@ API key card label: "API key -- subscription provider"
2. Call `loadScript()` to ensure Puter SDK is loaded.
3. Call `(window as any).puter.auth.signIn()` — this opens the popup. Must be in the same synchronous-ish call chain from the click event.
4. After signIn resolves, extract token: try `(window as any).puter.authToken` first, then `(window as any).puter.auth?.token`, then call `(window as any).puter.auth?.getUser?.()` and check for token on the returned object. Log a warning if token is undefined (per Pitfall 1 in RESEARCH.md).
5. If `companyId` is available, POST token to `/api/puter-proxy/token` via `puterProxyApi.storeToken(companyId, token)`. If `companyId` is not yet available (pre-company-creation), store token in a ref/state for later submission.
6. Call `onSuccess()`. Set `loading(false)`.
5. Call `onSuccess(token)` — parent stores the token in React state for later submission to server after company creation.
6. Set `connected(true)`, `loading(false)`.
7. On any error: `onError("Puter sign-in failed. Check your Puter.com account and try again.")`.
Render a `<Button>` (from @/components/ui/button). When `loading`, show spinner (h-4 w-4 animate-spin SVG, same pattern as NexusOnboardingWizard submit button) and text "Connecting to Puter...". When connected, show CheckCircle icon (from lucide-react) and text "Puter connected -- Continue". Otherwise show LogIn icon and text "Continue with Puter". Use `aria-busy={loading}`.
**IMPORTANT NOTE on companyId timing:** The company is not created until step 4 (root directory submit). The provider selection step (step 3) happens BEFORE company creation. The PuterAuthButton should store the token in component state and pass it up to the wizard. The wizard will POST it to the server after company creation in handleSubmit. Adjust the component accordingly: `onSuccess` receives the token string, parent stores it.
Render a `<Button>` (from @/components/ui/button). When `loading`, show spinner (h-4 w-4 animate-spin SVG) and text "Connecting to Puter...". When `connected`, show CheckCircle icon (from lucide-react) and text "Puter connected". Otherwise show LogIn icon and text "Continue with Puter". Use `aria-busy={loading}`.
**Create ui/src/components/onboarding/GoogleOAuthButton.tsx:**
Props: `{ companyId?: string; onSuccess: () => void; onError: (msg: string) => void }`.
Props: `{ onSuccess: (stateId: string) => void; onError: (msg: string) => void }`.
State: `loading`, `warningShown` (boolean, starts false), `warningTimer` (boolean, starts false — becomes true after 3 seconds).
State: `loading`, `warningTimer` (boolean, starts false — becomes true after 3 seconds), `connected` boolean.
On mount (or when component becomes visible), start a 3-second timeout that sets `warningTimer = true`. The "Sign in with Google" button is disabled until `warningTimer === true`.
On mount, start a 3-second timeout that sets `warningTimer = true`. Clean up timeout on unmount.
Render:
1. Policy-risk warning (always visible when this option is selected):
1. Policy-risk warning (always visible when this component is rendered):
```
<div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
<p className="font-medium">Policy risk -- read before continuing</p>
<p className="mt-1">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.</p>
</div>
```
2. Button: "Sign in with Google" (disabled for 3 seconds, then enabled). On click: call `puterProxyApi.getAuthUrl(companyId)` to get the auth URL, then `window.open(url, "_blank", "popup,width=600,height=700")`. Listen for the popup close or for `window.addEventListener("message", ...)` for a success signal. Alternatively, poll `window.location` for `?google_oauth=success` query param. Simpler approach: after opening popup, show "Signing in..." state. The callback redirects to `/?google_oauth=success`, and the parent page detects this.
**IMPORTANT NOTE on companyId timing (same as Puter):** Google OAuth flow also happens before company creation. The `POST /oauth/google/authorize` endpoint needs companyId to store the token. Solution: defer the OAuth flow to require companyId, OR have the authorize endpoint accept a temporary session identifier that gets linked to companyId later. Simpler: make the provider selection step collect the user's INTENT only (which provider they chose), and execute the actual auth flow during handleSubmit after company creation. This avoids the companyId timing issue entirely.
**Revised approach for all auth buttons:** Instead of executing auth immediately, the provider selection step records the user's CHOICE. Then:
- For Puter: the popup runs immediately (before company creation) because it's a user gesture. Store the token in React state. Post it to server during handleSubmit after company creation.
- For Google: similarly, open popup immediately, but the callback URL includes a temporary state ID. The server stores tokens keyed by state. After company creation, a second call links the tokens to the companyId.
- For API key: store in React state, post during handleSubmit.
Actually the SIMPLEST approach: just let all three auth paths run immediately and store tokens in React state. After company creation in handleSubmit, POST all collected tokens/keys to the server. This avoids needing companyId during the auth step.
The GoogleOAuthButton should: open popup to Google auth URL (using a temp state), wait for popup to redirect back to `/api/oauth/google/callback?code=...&state=...` which exchanges the code and stores temporarily. After the popup completes, signal success. During handleSubmit, the stored tokens get linked to the real companyId.
For simplicity in this phase: The Puter and Google auth buttons collect credentials client-side (Puter token in state, Google opens popup that returns to callback). The wizard handleSubmit posts credentials to server after company creation. The GoogleOAuthButton stores the OAuth code exchange result temporarily on the server keyed by state UUID, and handleSubmit calls a "claim" endpoint.
**Simplest viable approach:** Puter token is captured client-side in React state. Google OAuth and API key are also captured client-side. All are posted to server after company creation. The Google OAuth flow captures an auth code client-side via redirect, but since it's a server-side exchange, we need the callback to be server-side.
**Final approach:**
- Puter: signIn() popup -> capture token in React state -> post after company creation. Clean.
- Google: POST /oauth/google/authorize -> server returns { url, stateId }. Open popup to url. Server callback exchanges code, stores tokens keyed by stateId in a temp Map. After company creation, POST /oauth/google/claim { stateId, companyId } -> moves tokens from temp to secretService.
- API key: capture in React state -> post after company creation. Clean.
2. Button: "Sign in with Google" (disabled until `warningTimer === true`). On click:
a. Set `loading(true)`.
b. Call `puterProxyApi.getAuthUrl()` to get `{ url, stateId }`.
c. Open popup: `window.open(url, "_blank", "popup,width=600,height=700")`.
d. Poll for popup close or listen for `window.addEventListener("message", ...)`. Simpler: poll every 500ms checking if the popup is closed (`popup.closed`). When popup closes, check `window.location.search` for `google_oauth=success` query param, OR just trust that the callback happened server-side and the tokens are parked.
e. Actually, the simplest approach: after opening the popup, the Google OAuth callback redirects the popup to `/?google_oauth=success&state={stateId}`. The popup lands on that URL. Use `window.addEventListener("message")` — but since the popup is our own origin, we can poll it. Simplest: after popup opens, poll `popup.closed` every 500ms. When closed, assume success (the callback stored tokens server-side). Call `onSuccess(stateId)`.
f. Set `connected(true)`, `loading(false)`.
3. On error: call `onError(msg)`.
**Create ui/src/components/onboarding/ApiKeyEntryForm.tsx:**
Props: `{ onSave: (provider: string, apiKey: string) => void; onError: (msg: string) => void }`.
State: `provider` (default "openai"), `apiKey` (string), `saving` (boolean).
State: `provider` (default "openai"), `apiKey` (string), `saved` (boolean).
Render:
1. `<Label>` + select dropdown with options: OpenAI, Anthropic, Groq.
2. `<Label>` + `<Input>` for API key, type="password".
3. `<Button>` "Save API key". On click: validate apiKey not empty, call `onSave(provider, apiKey)`.
4. Use shadcn `Label`, `Input`, `Button` components. Inline form (no page navigation).
3. `<Button>` "Save API key". On click: validate apiKey not empty, call `onSave(provider, apiKey)`. Set `saved(true)`.
4. When `saved`, show CheckCircle icon and "API key saved" text.
5. Use shadcn `Label`, `Input`, `Button` components. Inline form (no page navigation).
</action>
<verify>
<automated>cd /opt/nexus && ls ui/src/api/puter-proxy.ts ui/src/components/onboarding/PuterAuthButton.tsx ui/src/components/onboarding/GoogleOAuthButton.tsx ui/src/components/onboarding/ApiKeyEntryForm.tsx</automated>
@ -218,6 +222,8 @@ API key card label: "API key -- subscription provider"
<acceptance_criteria>
- grep -q "puterProxyApi" ui/src/api/puter-proxy.ts
- grep -q "puter-proxy/token" ui/src/api/puter-proxy.ts
- grep -q "oauth/google/claim" ui/src/api/puter-proxy.ts
- grep -q "stateId" ui/src/api/puter-proxy.ts
- grep -q "js.puter.com/v2" ui/src/components/onboarding/PuterAuthButton.tsx
- grep -q "puter.auth.signIn" ui/src/components/onboarding/PuterAuthButton.tsx
- grep -q "Continue with Puter" ui/src/components/onboarding/PuterAuthButton.tsx
@ -230,7 +236,7 @@ API key card label: "API key -- subscription provider"
- grep -q "Save API key" ui/src/components/onboarding/ApiKeyEntryForm.tsx
- grep -q 'type="password"' ui/src/components/onboarding/ApiKeyEntryForm.tsx
</acceptance_criteria>
<done>PuterAuthButton loads CDN and triggers signIn popup, GoogleOAuthButton shows risk warning with 3s gate, ApiKeyEntryForm captures provider/key pairs, all pass credentials back to parent via callbacks</done>
<done>PuterAuthButton loads CDN and triggers signIn popup (token captured in React state), GoogleOAuthButton shows risk warning with 3s gate and opens OAuth popup (stateId captured), ApiKeyEntryForm captures provider/key pairs — all pass data back to parent via callbacks for post-company-creation submission</done>
</task>
<task type="auto">
@ -280,7 +286,7 @@ API key card label: "API key -- subscription provider"
4. Always show: `<Button variant="ghost" onClick={onSkip}>Skip for now</Button>` with `aria-label="Skip provider setup for now"`.
5. Show detected adapter badges on each card (run probes on mount):
5. Show detected adapter badges on each card:
For each adapter in `detectedAdapters` where value is true, show `<span className="text-xs text-primary">Hermes detected</span>` (or "Claude Code detected", "OpenClaw detected") inside the relevant card area.
6. Error display: if `error` is set, show `<p className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">{error}</p>`.
@ -310,11 +316,7 @@ API key card label: "API key -- subscription provider"
}
if (googleOAuthStateId) {
// Claim Google OAuth tokens for this company
await fetch("/api/oauth/google/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stateId: googleOAuthStateId, companyId: company.id }),
}).catch(() => {});
await puterProxyApi.claimGoogleTokens(googleOAuthStateId, company.id).catch(() => {});
}
if (apiKeyData) {
await puterProxyApi.storeApiKey(company.id, apiKeyData.provider, apiKeyData.apiKey).catch(() => {});
@ -336,11 +338,12 @@ API key card label: "API key -- subscription provider"
- grep -q "Step 3.*Provider\|ProviderSelectionStep" ui/src/components/NexusOnboardingWizard.tsx
- grep -q "Step {step} of 4\|of 4" ui/src/components/NexusOnboardingWizard.tsx
- grep -q "puterToken" ui/src/components/NexusOnboardingWizard.tsx
- grep -q "puter-proxy/token\|storeToken" ui/src/components/NexusOnboardingWizard.tsx
- grep -q "googleOAuthStateId" ui/src/components/NexusOnboardingWizard.tsx
- grep -q "claimGoogleTokens" ui/src/components/NexusOnboardingWizard.tsx
- grep -q "openclaw_gateway\|claude_local" ui/src/components/NexusOnboardingWizard.tsx
- TypeScript compilation passes (tsc --noEmit exits 0)
</acceptance_criteria>
<done>4-step wizard with ProviderSelectionStep at step 3, adapter auto-detection badges, Puter/Google/APIKey credentials collected and stored after company creation, Skip always available</done>
<done>4-step wizard with ProviderSelectionStep at step 3, adapter auto-detection badges for hermes/claude/openclaw, Puter token captured in state then posted after company creation, Google OAuth stateId captured then claimed after company creation, API key captured then stored after company creation, Skip always available</done>
</task>
</tasks>
@ -351,16 +354,17 @@ API key card label: "API key -- subscription provider"
- Wizard shows 4 steps with provider selection at step 3
- Provider cards match ModeSelector visual pattern
- All UI copy matches 31-UI-SPEC.md copywriting contract
- Google OAuth flow uses stateId/claim pattern (no companyId before company creation)
</verification>
<success_criteria>
- Provider Selection step renders three provider cards with correct copy
- Puter auth triggers popup and captures token
- Google OAuth shows risk warning with 3-second gate
- Puter auth triggers popup and captures token in React state
- Google OAuth shows risk warning with 3-second gate, opens popup, captures stateId
- API key form captures provider/key pairs
- Auto-detected adapters show badges on cards
- Skip for now always works
- Wizard is 4 steps, credentials stored after company creation
- Wizard is 4 steps, all credentials stored after company creation via handleSubmit
</success_criteria>
<output>

View file

@ -3,9 +3,8 @@ phase: 31-puter.js-zero-config-cloud
plan: "04"
type: execute
wave: 3
depends_on: ["31-03"]
files_modified:
- server/src/routes/google-oauth.ts
depends_on: ["31-02", "31-03"]
files_modified: []
autonomous: false
requirements: [CLOUD-01, CLOUD-02, CLOUD-03, CLOUD-04, CLOUD-05]
@ -15,14 +14,14 @@ must_haves:
- "Google OAuth risk warning is prominently visible before sign-in button enables"
- "API key entry form accepts and stores keys for subscription providers"
- "Skip for now creates a working workspace with detected adapter"
- "All provider credentials are stored server-side, not in localStorage"
- "No provider tokens or API keys appear in browser localStorage or sessionStorage (verify via DevTools Application tab)"
---
<objective>
Add the Google OAuth claim endpoint (needed to link OAuth tokens to a companyId after company creation) and perform a human verification of the complete provider selection flow.
Human verification of the complete provider selection flow. All implementation work is done in Plans 01-03 (including the /claim endpoint in Plan 02). This plan is purely visual/functional verification.
Purpose: Final wiring for the Google OAuth flow and visual/functional verification of all CLOUD requirements.
Output: OAuth claim endpoint, human verification of full onboarding wizard
Purpose: Final functional verification of all CLOUD requirements.
Output: Human verification of full onboarding wizard
</objective>
<execution_context>
@ -41,54 +40,21 @@ Output: OAuth claim endpoint, human verification of full onboarding wizard
<tasks>
<task type="auto">
<name>Task 1: Google OAuth claim endpoint + integration wiring</name>
<files>server/src/routes/google-oauth.ts</files>
<read_first>
server/src/routes/google-oauth.ts,
server/src/services/google-oauth.ts,
server/src/services/secrets.ts
</read_first>
<action>
Add a `POST /oauth/google/claim` route to `googleOAuthRoutes`:
The OAuth callback (from plan 02) stores tokens in a temporary in-memory Map keyed by `stateId`. This claim endpoint moves them to permanent secretService storage under a real companyId.
1. `assertBoard(req)`.
2. Extract `stateId` and `companyId` from `req.body`. Validate both are non-empty strings.
3. `assertCompanyAccess(req, companyId)`.
4. Look up temporary token storage by `stateId`. If not found, return `res.status(404).json({ error: "OAuth session expired or not found" })`.
5. Call `googleOAuthService(db).storeTokens(companyId, tokens)`.
6. Delete from temporary storage.
7. Return `res.json({ ok: true })`.
Also update the existing callback handler (GET /oauth/google/callback) to store tokens in the temporary Map (keyed by stateId from the `state` query param) instead of requiring a companyId at callback time. The callback should: exchange code for tokens, store tokens temporarily in a Map `pendingTokens.set(state, { accessToken, refreshToken, createdAt: Date.now() })`, then redirect to `/?google_oauth=success`.
Add cleanup: in the claim handler, clean up entries older than 10 minutes from `pendingTokens`.
</action>
<verify>
<automated>cd /opt/nexus && grep -c "oauth/google/claim\|pendingTokens" server/src/routes/google-oauth.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "oauth/google/claim" server/src/routes/google-oauth.ts
- grep -q "pendingTokens" server/src/routes/google-oauth.ts
- grep -q "storeTokens" server/src/routes/google-oauth.ts
- grep -q "assertBoard" server/src/routes/google-oauth.ts
</acceptance_criteria>
<done>Google OAuth claim endpoint links temporary tokens to a real companyId via secretService; stale entries cleaned up after 10 minutes</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Visual and functional verification of onboarding wizard</name>
<what-built>
Complete 4-step onboarding wizard with provider selection:
<name>Task 1: Visual and functional verification of onboarding wizard</name>
<files>none — verification only</files>
<action>
Present the following verification checklist to the user. All implementation is complete from Plans 01-03.
What was built:
- Complete 4-step onboarding wizard with provider selection
- Step 1: Hardware detection (unchanged from Phase 30)
- Step 2: Mode selection (unchanged from Phase 30)
- Step 3: Provider selection (NEW) with Puter, Google, and API Key options
- Step 4: Root directory (unchanged)
Plus: server-side Puter proxy, Google OAuth, API key storage, cost tracking
</what-built>
<how-to-verify>
- Server-side Puter proxy, Google OAuth with pendingTokens/claim pattern, API key storage, cost tracking
How to verify:
1. Start Nexus dev server: `cd /opt/nexus && pnpm dev`
2. Open browser to localhost:3100 in an incognito window (fresh state)
3. The onboarding wizard should open automatically
@ -106,9 +72,15 @@ Output: OAuth claim endpoint, human verification of full onboarding wizard
i. Click "Skip for now" to advance to Step 4
7. On Step 4: verify root directory form appears, enter a path, click "Get Started"
8. Verify workspace is created and you reach the dashboard
9. (Optional) If you have a Puter.com account: repeat flow, select Puter, click "Continue with Puter", verify popup opens
</how-to-verify>
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
9. Open DevTools > Application tab > Local Storage and Session Storage — verify NO provider tokens or API keys are stored there
10. (Optional) If you have a Puter.com account: repeat flow, select Puter, click "Continue with Puter", verify popup opens
Resume signal: Type "approved" or describe issues to fix.
</action>
<verify>
<automated>echo "checkpoint:human-verify — requires manual approval"</automated>
</verify>
<done>Human approves that all 5 CLOUD requirements work end-to-end in the onboarding wizard, no credentials in browser storage</done>
</task>
</tasks>
@ -116,9 +88,9 @@ Output: OAuth claim endpoint, human verification of full onboarding wizard
<verification>
- Full onboarding wizard flow works end-to-end
- All 5 CLOUD requirements are addressed:
- CLOUD-01: Puter zero-config path (popup -> token -> server proxy)
- CLOUD-02: Server-proxied adapter with cost tracking
- CLOUD-03: Google OAuth PKCE with risk warning
- CLOUD-01: Puter zero-config path (popup -> token in React state -> post to server after company creation)
- CLOUD-02: Server-proxied adapter with cost tracking (agentId optional for pre-agent calls)
- CLOUD-03: Google OAuth PKCE with risk warning (pendingTokens/claim pattern)
- CLOUD-04: Auto-detected tools show badges
- CLOUD-05: API key entry for subscription providers
</verification>
@ -128,6 +100,7 @@ Output: OAuth claim endpoint, human verification of full onboarding wizard
- 4-step wizard works with all provider paths
- No console errors in browser
- Server logs show no unhandled exceptions
- No credentials in browser storage (all server-side)
</success_criteria>
<output>