docs(31): create phase plan for Puter.js Zero-Config Cloud
4 plans across 3 waves covering all 5 CLOUD requirements: - Plan 01 (W1): Puter proxy service, routes, tests (CLOUD-01, CLOUD-02) - Plan 02 (W1): Google OAuth PKCE + API key storage (CLOUD-03, CLOUD-05) - Plan 03 (W2): Provider Selection UI, 4-step wizard (CLOUD-01/03/04/05) - Plan 04 (W3): OAuth claim endpoint + human verification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
deffc1173d
commit
214df47bf5
5 changed files with 974 additions and 2 deletions
|
|
@ -124,7 +124,13 @@ Plans:
|
|||
3. Token cost for Puter responses appears in the cost tracking view, attributed correctly per conversation
|
||||
4. A user with Hermes, Claude Code, or OpenClaw already installed sees those tools pre-filled in the provider configuration step with no manual entry
|
||||
5. A user clicking "Sign in with Google" for Gemini completes PKCE OAuth and gets a Gemini-backed chat response; the UI displays a policy-risk note that Google OAuth may trigger abuse detection
|
||||
**Plans**: TBD
|
||||
**Plans**: 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 31-01-PLAN.md — Puter proxy service, routes, unit tests, and app.ts wiring
|
||||
- [ ] 31-02-PLAN.md — Google OAuth PKCE service, routes, API key storage route
|
||||
- [ ] 31-03-PLAN.md — Provider Selection UI step, PuterAuthButton, GoogleOAuthButton, ApiKeyEntryForm, 4-step wizard wiring
|
||||
- [ ] 31-04-PLAN.md — Google OAuth claim endpoint, human verification of full onboarding flow
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 32: Multi-Step Onboarding Wizard
|
||||
|
|
@ -216,7 +222,7 @@ All 21 v1.5 requirements are mapped to exactly one phase. No orphans.
|
|||
| 28. Ollama Integration & Agent Surface | v1.4 | 3/3 | Complete | 2026-04-02 |
|
||||
| 29. Default Provider & End-to-End | v1.4 | 2/2 | Complete | 2026-04-02 |
|
||||
| 30. Hardware Detection + Mode Selection | v1.5 | 2/2 | Complete | 2026-04-03 |
|
||||
| 31. Puter.js Zero-Config Cloud | v1.5 | 0/TBD | Not started | - |
|
||||
| 31. Puter.js Zero-Config Cloud | v1.5 | 0/4 | Not started | - |
|
||||
| 32. Multi-Step Onboarding Wizard | v1.5 | 0/TBD | Not started | - |
|
||||
| 33. Persistent Memory + Personal Assistant Mode | v1.5 | 0/TBD | Not started | - |
|
||||
| 34. Voice | v1.5 | 0/TBD | Not started | - |
|
||||
|
|
|
|||
253
.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md
Normal file
253
.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
---
|
||||
phase: 31-puter.js-zero-config-cloud
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- server/src/services/puter-proxy.ts
|
||||
- server/src/routes/puter-proxy.ts
|
||||
- server/src/__tests__/31-puter-proxy.test.ts
|
||||
- server/src/app.ts
|
||||
autonomous: true
|
||||
requirements: [CLOUD-01, CLOUD-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Puter auth token can be stored server-side via secretService and retrieved for API calls"
|
||||
- "POST /api/puter-proxy/chat relays messages to Puter OpenAI-compat endpoint with correct Bearer header"
|
||||
- "POST /api/puter-proxy/chat streams SSE tokens back to the client"
|
||||
- "Cost events are recorded after every Puter chat completion with provider=puter and billingType=subscription_included"
|
||||
- "POST /api/puter-proxy/token stores (or rotates) the Puter auth token idempotently"
|
||||
artifacts:
|
||||
- path: "server/src/services/puter-proxy.ts"
|
||||
provides: "puterProxyService with resolveToken, chatStream, storeToken methods"
|
||||
exports: ["puterProxyService"]
|
||||
- path: "server/src/routes/puter-proxy.ts"
|
||||
provides: "Express routes for POST /api/puter-proxy/chat and POST /api/puter-proxy/token"
|
||||
exports: ["puterProxyRoutes"]
|
||||
- path: "server/src/__tests__/31-puter-proxy.test.ts"
|
||||
provides: "Unit tests for token storage, proxy streaming, cost recording"
|
||||
key_links:
|
||||
- from: "server/src/routes/puter-proxy.ts"
|
||||
to: "server/src/services/puter-proxy.ts"
|
||||
via: "import puterProxyService"
|
||||
pattern: "puterProxyService"
|
||||
- from: "server/src/services/puter-proxy.ts"
|
||||
to: "server/src/services/secrets.ts"
|
||||
via: "secretService getByName/create/rotate"
|
||||
pattern: "secretService.*getByName|create|rotate"
|
||||
- from: "server/src/services/puter-proxy.ts"
|
||||
to: "server/src/services/costs.ts"
|
||||
via: "costService.createEvent with provider=puter"
|
||||
pattern: "createEvent.*puter"
|
||||
- from: "server/src/app.ts"
|
||||
to: "server/src/routes/puter-proxy.ts"
|
||||
via: "api.use(puterProxyRoutes(db))"
|
||||
pattern: "puterProxyRoutes"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the server-side Puter proxy service and routes that relay AI chat requests to Puter's OpenAI-compatible endpoint, store/rotate auth tokens via secretService, stream SSE responses, and record cost events.
|
||||
|
||||
Purpose: This is the core backend for CLOUD-01 (zero-config AI via Puter) and CLOUD-02 (server-proxied adapter with cost tracking). Without this, the UI has nothing to call.
|
||||
Output: puterProxyService, puterProxyRoutes, unit tests, app.ts wiring
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From server/src/services/secrets.ts:
|
||||
```typescript
|
||||
export function secretService(db: Db) {
|
||||
// ...
|
||||
return {
|
||||
getByName: async (companyId: string, name: string) => row | null,
|
||||
create: async (companyId: string, input: { name: string; provider: SecretProvider; value: string; description?: string | null }, actor?) => secret,
|
||||
rotate: async (secretId: string, input: { value: string }, actor?) => secret,
|
||||
resolveSecretValue: async (companyId: string, secretId: string, version: number | "latest") => string,
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/services/costs.ts:
|
||||
```typescript
|
||||
export function costService(db: Db, budgetHooks?) {
|
||||
return {
|
||||
createEvent: async (companyId: string, data: {
|
||||
agentId: string;
|
||||
provider: string;
|
||||
biller?: string;
|
||||
billingType?: string;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
occurredAt: Date;
|
||||
cachedInputTokens?: number;
|
||||
}) => event,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/routes/authz.ts:
|
||||
```typescript
|
||||
export function assertBoard(req: Request): void;
|
||||
export function assertCompanyAccess(req: Request, companyId: string): void;
|
||||
```
|
||||
|
||||
From server/src/routes/chat.ts (SSE pattern, lines 96-102):
|
||||
```typescript
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
res.write(":ok\n\n");
|
||||
```
|
||||
|
||||
From server/src/app.ts (route mounting, lines 155-158):
|
||||
```typescript
|
||||
api.use(secretRoutes(db));
|
||||
api.use(costRoutes(db));
|
||||
// ... new routes go here, inside the api Router (after boardMutationGuard)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: puterProxyService + puterProxyRoutes + unit tests</name>
|
||||
<files>
|
||||
server/src/services/puter-proxy.ts,
|
||||
server/src/routes/puter-proxy.ts,
|
||||
server/src/__tests__/31-puter-proxy.test.ts
|
||||
</files>
|
||||
<read_first>
|
||||
server/src/services/secrets.ts,
|
||||
server/src/services/costs.ts,
|
||||
server/src/routes/chat.ts,
|
||||
server/src/routes/secrets.ts,
|
||||
server/src/routes/authz.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 1: storeToken creates a new secret via secretService when none exists (name: "puter_auth_token", provider: "local_encrypted")
|
||||
- Test 2: storeToken rotates an existing secret when "puter_auth_token" already exists (calls rotate, not create)
|
||||
- Test 3: resolveToken retrieves the stored token value via secretService.resolveSecretValue
|
||||
- Test 4: chatStream sends POST to https://api.puter.com/puterai/openai/v1/chat/completions with Authorization Bearer header, stream: true, stream_options: { include_usage: true }
|
||||
- Test 5: chatStream yields content strings from SSE data chunks
|
||||
- Test 6: chatStream records a cost event with provider="puter", billingType="subscription_included", costCents=0 after stream completes
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
**Create server/src/services/puter-proxy.ts:**
|
||||
|
||||
Factory function `puterProxyService(db: Db)` that uses `secretService(db)` and `costService(db)` internally. Returns:
|
||||
|
||||
- `storeToken(companyId: string, token: string)`: Call `secretService.getByName(companyId, "puter_auth_token")`. If exists, call `secretService.rotate(existing.id, { value: token })`. If not, call `secretService.create(companyId, { name: "puter_auth_token", provider: "local_encrypted", value: token, description: "Puter.com auth token for AI proxy" })`. Return the secret record.
|
||||
|
||||
- `resolveToken(companyId: string)`: Call `secretService.getByName(companyId, "puter_auth_token")`. Throw `unprocessable("Puter auth token not configured")` if null. Call `secretService.resolveSecretValue(companyId, secret.id, "latest")` and return string.
|
||||
|
||||
- `async *chatStream(companyId, agentId, messages, model, signal)`: AsyncGenerator<string>.
|
||||
- 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(() => {})`.
|
||||
|
||||
**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. `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.
|
||||
|
||||
Import patterns: `import { puterProxyService } from "../services/puter-proxy.js"` and `import { puterProxyRoutes } from "../routes/puter-proxy.js"`. For route tests, use supertest with a minimal Express app.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "puterProxyService" server/src/services/puter-proxy.ts
|
||||
- grep -q "puter_auth_token" server/src/services/puter-proxy.ts
|
||||
- grep -q "api.puter.com/puterai/openai/v1" server/src/services/puter-proxy.ts
|
||||
- grep -q "subscription_included" server/src/services/puter-proxy.ts
|
||||
- grep -q "costCents: 0" server/src/services/puter-proxy.ts
|
||||
- grep -q "stream_options" server/src/services/puter-proxy.ts
|
||||
- grep -q "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
|
||||
</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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Mount puterProxyRoutes in app.ts</name>
|
||||
<files>server/src/app.ts</files>
|
||||
<read_first>server/src/app.ts</read_first>
|
||||
<action>
|
||||
Add import at top of server/src/app.ts:
|
||||
```typescript
|
||||
import { puterProxyRoutes } from "./routes/puter-proxy.js";
|
||||
```
|
||||
|
||||
Mount inside the `api` Router block (after `boardMutationGuard()`, near the other route mounts around line 158), add:
|
||||
```typescript
|
||||
api.use(puterProxyRoutes(db));
|
||||
```
|
||||
|
||||
Place it after `api.use(costRoutes(db));` (line 158) to keep related routes together. This ensures the route is protected by boardMutationGuard (correct — Puter proxy requires board auth).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -n "puterProxyRoutes" server/src/app.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "import.*puterProxyRoutes.*from.*routes/puter-proxy" server/src/app.ts
|
||||
- grep -q "api.use(puterProxyRoutes(db))" server/src/app.ts
|
||||
</acceptance_criteria>
|
||||
<done>puterProxyRoutes mounted inside api Router with board auth protection; server compiles without errors</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts` — all tests pass
|
||||
- `cd /opt/nexus && pnpm --filter @paperclipai/server exec tsc --noEmit` — no TypeScript errors
|
||||
- grep confirms route is mounted in app.ts
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Puter proxy service exists with token storage, streaming chat, and cost tracking
|
||||
- Routes exist for POST /puter-proxy/token and POST /puter-proxy/chat
|
||||
- Routes mounted in app.ts behind boardMutationGuard
|
||||
- All unit tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-01-SUMMARY.md`
|
||||
</output>
|
||||
210
.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md
Normal file
210
.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
---
|
||||
phase: 31-puter.js-zero-config-cloud
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- server/src/services/google-oauth.ts
|
||||
- server/src/routes/google-oauth.ts
|
||||
- server/src/app.ts
|
||||
autonomous: true
|
||||
requirements: [CLOUD-03, CLOUD-05]
|
||||
|
||||
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)"
|
||||
artifacts:
|
||||
- path: "server/src/services/google-oauth.ts"
|
||||
provides: "googleOAuthService with generateAuthUrl, exchangeCode, storeTokens methods"
|
||||
exports: ["googleOAuthService"]
|
||||
- path: "server/src/routes/google-oauth.ts"
|
||||
provides: "Routes for GET /api/oauth/google/authorize and GET /api/oauth/google/callback"
|
||||
exports: ["googleOAuthRoutes"]
|
||||
key_links:
|
||||
- from: "server/src/services/google-oauth.ts"
|
||||
to: "server/src/services/secrets.ts"
|
||||
via: "secretService getByName/create/rotate for google_gemini_oauth_token"
|
||||
pattern: "google_gemini_oauth_token"
|
||||
- from: "server/src/routes/google-oauth.ts"
|
||||
to: "server/src/services/google-oauth.ts"
|
||||
via: "import googleOAuthService"
|
||||
pattern: "googleOAuthService"
|
||||
---
|
||||
|
||||
<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.
|
||||
|
||||
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
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
From server/src/services/secrets.ts:
|
||||
```typescript
|
||||
export function secretService(db: Db) {
|
||||
return {
|
||||
getByName: async (companyId: string, name: string) => row | null,
|
||||
create: async (companyId, { name, provider, value, description }, actor?) => secret,
|
||||
rotate: async (secretId, { value }, actor?) => secret,
|
||||
resolveSecretValue: async (companyId, secretId, "latest") => string,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/routes/authz.ts:
|
||||
```typescript
|
||||
export function assertBoard(req): void;
|
||||
export function assertCompanyAccess(req, companyId): void;
|
||||
```
|
||||
|
||||
From server/src/app.ts (route mounting):
|
||||
```typescript
|
||||
// Unauthenticated routes mounted before api Router:
|
||||
app.use("/api", hardwareRoutes()); // line 132
|
||||
|
||||
// Authenticated routes inside api Router:
|
||||
api.use(secretRoutes(db)); // line 157
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: googleOAuthService — PKCE generation, code exchange, token storage</name>
|
||||
<files>server/src/services/google-oauth.ts</files>
|
||||
<read_first>
|
||||
server/src/services/secrets.ts,
|
||||
server/src/routes/secrets.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Create `server/src/services/google-oauth.ts`. Export `googleOAuthService(db: Db)`.
|
||||
|
||||
**Constants:**
|
||||
```typescript
|
||||
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const GEMINI_CLIENT_ID = "812546505895-ag9nvbqvf8cpqk3mfem1glig0jtl5i31.apps.googleusercontent.com";
|
||||
const GEMINI_SCOPES = "openid https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever";
|
||||
const GOOGLE_TOKEN_SECRET_NAME = "google_gemini_oauth_token";
|
||||
```
|
||||
|
||||
The client_id is the publicly documented Gemini CLI installed-app client_id (per RESEARCH.md).
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `generatePkce()`: Use `crypto.randomBytes(32).toString("base64url")` for verifier. `crypto.createHash("sha256").update(verifier).digest("base64url")` for challenge. Return `{ verifier, challenge }`.
|
||||
|
||||
- `generateAuthUrl(redirectUri: string, state: string)`: Generate PKCE. Build URL with params: `client_id=GEMINI_CLIENT_ID`, `redirect_uri=redirectUri`, `response_type=code`, `scope=GEMINI_SCOPES`, `code_challenge=challenge`, `code_challenge_method=S256`, `state=state`, `access_type=offline`, `prompt=consent`. Return `{ url, verifier }`. Caller must store verifier temporarily (in-memory Map keyed by state).
|
||||
|
||||
- `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`.
|
||||
|
||||
- `resolveTokens(companyId: string)`: Get secret by name, resolve value, JSON.parse, return `{ accessToken, refreshToken }`.
|
||||
|
||||
Use `import crypto from "node:crypto";` for PKCE. Use `fetch` (Node built-in) for token exchange.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && grep -c "generatePkce\|exchangeCode\|storeTokens\|resolveTokens\|generateAuthUrl" server/src/services/google-oauth.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "googleOAuthService" server/src/services/google-oauth.ts
|
||||
- grep -q "google_gemini_oauth_token" server/src/services/google-oauth.ts
|
||||
- grep -q "812546505895" server/src/services/google-oauth.ts
|
||||
- grep -q "code_challenge_method" server/src/services/google-oauth.ts
|
||||
- grep -q "randomBytes" server/src/services/google-oauth.ts
|
||||
- grep -q "base64url" server/src/services/google-oauth.ts
|
||||
- grep -q "oauth2.googleapis.com/token" server/src/services/google-oauth.ts
|
||||
</acceptance_criteria>
|
||||
<done>googleOAuthService generates PKCE auth URLs, exchanges codes for tokens, and stores/retrieves tokens via secretService</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: googleOAuthRoutes + API key storage route + mount in app.ts</name>
|
||||
<files>
|
||||
server/src/routes/google-oauth.ts,
|
||||
server/src/app.ts
|
||||
</files>
|
||||
<read_first>
|
||||
server/src/app.ts,
|
||||
server/src/routes/secrets.ts,
|
||||
server/src/routes/authz.ts
|
||||
</read_first>
|
||||
<action>
|
||||
**Create server/src/routes/google-oauth.ts:**
|
||||
|
||||
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.
|
||||
|
||||
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 })`.
|
||||
|
||||
- `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`.
|
||||
|
||||
- `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 })`.
|
||||
|
||||
**Mount in app.ts:**
|
||||
|
||||
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:
|
||||
|
||||
```typescript
|
||||
api.use(googleOAuthRoutes(db));
|
||||
```
|
||||
|
||||
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>
|
||||
</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 "pendingPkce" server/src/routes/google-oauth.ts
|
||||
- grep -q "assertBoard" server/src/routes/google-oauth.ts
|
||||
- grep -q "import.*googleOAuthRoutes" server/src/app.ts
|
||||
- grep -q "api.use(googleOAuthRoutes" server/src/app.ts
|
||||
</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>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</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
|
||||
- All routes mounted in app.ts
|
||||
- Server compiles without TypeScript errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-02-SUMMARY.md`
|
||||
</output>
|
||||
368
.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md
Normal file
368
.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
---
|
||||
phase: 31-puter.js-zero-config-cloud
|
||||
plan: "03"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["31-01", "31-02"]
|
||||
files_modified:
|
||||
- ui/src/components/onboarding/ProviderSelectionStep.tsx
|
||||
- ui/src/components/onboarding/PuterAuthButton.tsx
|
||||
- ui/src/components/onboarding/GoogleOAuthButton.tsx
|
||||
- ui/src/components/onboarding/ApiKeyEntryForm.tsx
|
||||
- ui/src/components/NexusOnboardingWizard.tsx
|
||||
- ui/src/api/puter-proxy.ts
|
||||
autonomous: true
|
||||
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"
|
||||
- "Google option shows policy-risk warning before enabling the Sign in button (3-second gate)"
|
||||
- "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)"
|
||||
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"
|
||||
exports: ["PuterAuthButton"]
|
||||
- path: "ui/src/components/onboarding/GoogleOAuthButton.tsx"
|
||||
provides: "Google OAuth button with 3-second risk warning gate"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
pattern: "oauth/google/authorize"
|
||||
- from: "ui/src/components/onboarding/ApiKeyEntryForm.tsx"
|
||||
to: "POST /api/api-keys/store"
|
||||
via: "fetch call to store API key"
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the Provider Selection step UI components and wire them into the existing 3-step onboarding wizard (making it 4 steps). This includes the Puter auth button, Google OAuth button with risk warning, API key entry form, and adapter auto-detection badges.
|
||||
|
||||
Purpose: CLOUD-01 (Puter zero-config UI), CLOUD-03 (Google OAuth UI), CLOUD-04 (auto-detect tools), CLOUD-05 (API key entry UI). This is the user-facing surface for all cloud provider paths.
|
||||
Output: ProviderSelectionStep, PuterAuthButton, GoogleOAuthButton, ApiKeyEntryForm, updated wizard
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md
|
||||
@.planning/phases/30-hardware-detection-mode-selection/30-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
From ui/src/components/NexusOnboardingWizard.tsx (current state):
|
||||
```typescript
|
||||
// 3-step wizard: step 1 = hardware, step 2 = mode, step 3 = root directory
|
||||
// Step indicator: "Step {step} of 3"
|
||||
// defaultAdapter state: "claude_local" | "hermes_local"
|
||||
// probing state for adapter detection
|
||||
// handleSubmit creates company + agents on step 3 form submit
|
||||
```
|
||||
|
||||
From ui/src/components/onboarding/ModeSelector.tsx (selected state pattern):
|
||||
```typescript
|
||||
// Cards use: border-primary bg-primary/5 when selected
|
||||
// Vertical stack with gap-3
|
||||
// Each card is a <button type="button"> element
|
||||
```
|
||||
|
||||
From ui/src/api/agents.ts:
|
||||
```typescript
|
||||
probeAdapter: (type: string) =>
|
||||
api.get<{ available: boolean; status: string }>(`/adapters/${encodeURIComponent(type)}/probe`),
|
||||
```
|
||||
|
||||
From 31-UI-SPEC.md (copywriting):
|
||||
```
|
||||
Step heading: "Choose a provider"
|
||||
Step subheading: "No API keys needed for the zero-config path."
|
||||
Puter card label: "Puter -- free, zero-config"
|
||||
Puter card description: "Free AI powered by your Puter.com account. No API key needed."
|
||||
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"
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: API client + PuterAuthButton + GoogleOAuthButton + ApiKeyEntryForm</name>
|
||||
<files>
|
||||
ui/src/api/puter-proxy.ts,
|
||||
ui/src/components/onboarding/PuterAuthButton.tsx,
|
||||
ui/src/components/onboarding/GoogleOAuthButton.tsx,
|
||||
ui/src/components/onboarding/ApiKeyEntryForm.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
ui/src/api/agents.ts,
|
||||
ui/src/api/client.ts,
|
||||
ui/src/components/onboarding/ModeSelector.tsx,
|
||||
ui/src/components/NexusOnboardingWizard.tsx,
|
||||
.planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md
|
||||
</read_first>
|
||||
<action>
|
||||
**Create ui/src/api/puter-proxy.ts:**
|
||||
|
||||
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 })`
|
||||
- `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 }`.
|
||||
|
||||
State: `loading` 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.
|
||||
|
||||
`handleClick` (the onClick handler — MUST be synchronous entry point for popup to work):
|
||||
1. Set `loading(true)`.
|
||||
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)`.
|
||||
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.
|
||||
|
||||
**Create ui/src/components/onboarding/GoogleOAuthButton.tsx:**
|
||||
|
||||
Props: `{ companyId?: string; onSuccess: () => void; onError: (msg: string) => void }`.
|
||||
|
||||
State: `loading`, `warningShown` (boolean, starts false), `warningTimer` (boolean, starts false — becomes true after 3 seconds).
|
||||
|
||||
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`.
|
||||
|
||||
Render:
|
||||
1. Policy-risk warning (always visible when this option is selected):
|
||||
```
|
||||
<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.
|
||||
|
||||
**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).
|
||||
|
||||
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).
|
||||
</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>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "puterProxyApi" ui/src/api/puter-proxy.ts
|
||||
- grep -q "puter-proxy/token" 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
|
||||
- grep -q "Connecting to Puter" ui/src/components/onboarding/PuterAuthButton.tsx
|
||||
- grep -q "Policy risk" ui/src/components/onboarding/GoogleOAuthButton.tsx
|
||||
- grep -q "suspended accounts" ui/src/components/onboarding/GoogleOAuthButton.tsx
|
||||
- grep -q "Sign in with Google" ui/src/components/onboarding/GoogleOAuthButton.tsx
|
||||
- grep -q "3000\|3 .* second\|setTimeout" ui/src/components/onboarding/GoogleOAuthButton.tsx
|
||||
- grep -q "openai\|anthropic\|groq" ui/src/components/onboarding/ApiKeyEntryForm.tsx
|
||||
- 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>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ProviderSelectionStep + wire 4-step wizard</name>
|
||||
<files>
|
||||
ui/src/components/onboarding/ProviderSelectionStep.tsx,
|
||||
ui/src/components/NexusOnboardingWizard.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
ui/src/components/NexusOnboardingWizard.tsx,
|
||||
ui/src/components/onboarding/ModeSelector.tsx,
|
||||
ui/src/components/onboarding/PuterAuthButton.tsx,
|
||||
ui/src/components/onboarding/GoogleOAuthButton.tsx,
|
||||
ui/src/components/onboarding/ApiKeyEntryForm.tsx,
|
||||
ui/src/api/agents.ts,
|
||||
.planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md
|
||||
</read_first>
|
||||
<action>
|
||||
**Create ui/src/components/onboarding/ProviderSelectionStep.tsx:**
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface ProviderSelectionStepProps {
|
||||
onPuterToken: (token: string) => void;
|
||||
onGoogleOAuthState: (stateId: string) => void;
|
||||
onApiKey: (provider: string, apiKey: string) => void;
|
||||
onSkip: () => void;
|
||||
onContinue: () => void;
|
||||
detectedAdapters: Record<string, boolean>; // e.g., { hermes_local: true, claude_local: false }
|
||||
}
|
||||
```
|
||||
|
||||
State: `selectedProvider` ("puter" | "google" | "apikey" | null), `providerReady` boolean (true when auth is complete for selected provider), `error` string | null.
|
||||
|
||||
Layout (following ModeSelector card pattern from 30-02):
|
||||
1. Three vertical cards in a stack (`flex flex-col gap-3`):
|
||||
- **Puter card**: `<button type="button">` with `border-primary bg-primary/5` when selected. Label: "Puter -- free, zero-config". Description: "Free AI powered by your Puter.com account. No API key needed." If any adapters detected, show badges inside card: `<span className="text-xs text-primary">{Name} detected</span>`.
|
||||
- **Google card**: Same pattern. Label: "Google -- Gemini free tier". Description: "Sign in with Google to access Gemini via your Google account."
|
||||
- **API key card**: Same pattern. Label: "API key -- subscription provider". Description: "Use your own OpenAI, Anthropic, or Groq API key."
|
||||
|
||||
2. Below cards, conditionally render the action component for the selected provider:
|
||||
- Puter selected: `<PuterAuthButton onSuccess={(token) => { onPuterToken(token); setProviderReady(true); }} onError={setError} />`
|
||||
- Google selected: `<GoogleOAuthButton onSuccess={(stateId) => { onGoogleOAuthState(stateId); setProviderReady(true); }} onError={setError} />`
|
||||
- API key selected: `<ApiKeyEntryForm onSave={(prov, key) => { onApiKey(prov, key); setProviderReady(true); }} onError={setError} />`
|
||||
|
||||
3. If `providerReady`: show a "Continue" button calling `onContinue()`.
|
||||
|
||||
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):
|
||||
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>`.
|
||||
|
||||
**Update ui/src/components/NexusOnboardingWizard.tsx:**
|
||||
|
||||
Changes:
|
||||
1. Import `ProviderSelectionStep` from `./onboarding/ProviderSelectionStep`.
|
||||
2. Import `puterProxyApi` from `../api/puter-proxy`.
|
||||
3. Change step count from 3 to 4. Step indicator: `"Step {step} of 4"`.
|
||||
4. Add state: `puterToken` (string | null), `googleOAuthStateId` (string | null), `apiKeyData` ({ provider: string; apiKey: string } | null).
|
||||
5. Add state: `detectedAdapters` (Record<string, boolean>, default `{}`).
|
||||
6. Update the adapter probe useEffect to also probe `claude_local` and `openclaw_gateway` (in parallel with existing `hermes_local` probe). Store results in `detectedAdapters`.
|
||||
7. Renumber steps:
|
||||
- Step 1: Hardware Detection (unchanged)
|
||||
- Step 2: Mode Selection (unchanged)
|
||||
- Step 3: Provider Selection (NEW)
|
||||
- Step 4: Root Directory (was step 3)
|
||||
8. Step 2 "Continue" now goes to step 3 (not step 3/root dir).
|
||||
9. Step 3 renders `<ProviderSelectionStep>`. "Skip" and "Continue" both advance to step 4. Back goes to step 2.
|
||||
10. Step 4 is the existing root directory form. Back goes to step 3.
|
||||
11. In `handleSubmit` (after company creation, before navigate), add credential storage:
|
||||
```typescript
|
||||
// Store collected provider credentials after company creation
|
||||
if (puterToken) {
|
||||
await puterProxyApi.storeToken(company.id, puterToken).catch(() => {});
|
||||
}
|
||||
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(() => {});
|
||||
}
|
||||
if (apiKeyData) {
|
||||
await puterProxyApi.storeApiKey(company.id, apiKeyData.provider, apiKeyData.apiKey).catch(() => {});
|
||||
}
|
||||
```
|
||||
All credential storage is non-blocking (catch(() => {})) — same pattern as mode save.
|
||||
12. Reset new state variables in the reset effect (when wizard closes): `setPuterToken(null)`, `setGoogleOAuthStateId(null)`, `setApiKeyData(null)`.
|
||||
13. Update heading for step 3: "Choose a provider" with subheading "No API keys needed for the zero-config path."
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus && pnpm --filter @paperclipai/ui exec tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep -q "ProviderSelectionStep" ui/src/components/onboarding/ProviderSelectionStep.tsx
|
||||
- grep -q "Choose a provider" ui/src/components/onboarding/ProviderSelectionStep.tsx
|
||||
- grep -q "Skip for now" ui/src/components/onboarding/ProviderSelectionStep.tsx
|
||||
- grep -q "border-primary bg-primary/5" ui/src/components/onboarding/ProviderSelectionStep.tsx
|
||||
- grep -q "detected" ui/src/components/onboarding/ProviderSelectionStep.tsx
|
||||
- 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 "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>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus && pnpm --filter @paperclipai/ui exec tsc --noEmit` — no TypeScript errors
|
||||
- All new components exist with correct exports
|
||||
- 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
|
||||
</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
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-03-SUMMARY.md`
|
||||
</output>
|
||||
135
.planning/phases/31-puter.js-zero-config-cloud/31-04-PLAN.md
Normal file
135
.planning/phases/31-puter.js-zero-config-cloud/31-04-PLAN.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
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
|
||||
autonomous: false
|
||||
requirements: [CLOUD-01, CLOUD-02, CLOUD-03, CLOUD-04, CLOUD-05]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Complete onboarding flow works end-to-end with Puter provider path"
|
||||
- "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"
|
||||
---
|
||||
|
||||
<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.
|
||||
|
||||
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
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/mikkel/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-01-SUMMARY.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-02-SUMMARY.md
|
||||
@.planning/phases/31-puter.js-zero-config-cloud/31-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<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:
|
||||
- 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>
|
||||
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
|
||||
4. Verify step indicator shows "Step 1 of 4"
|
||||
5. Click Continue through Step 1 (hardware) and Step 2 (mode)
|
||||
6. On Step 3 ("Choose a provider"):
|
||||
a. Verify three provider cards are visible (Puter, Google, API Key)
|
||||
b. Verify "Skip for now" button is visible below cards
|
||||
c. Click the Puter card — verify it gets the selected border highlight
|
||||
d. Verify "Continue with Puter" button appears
|
||||
e. Click the Google card — verify policy risk warning appears with red/amber text
|
||||
f. Verify "Sign in with Google" button is disabled for ~3 seconds
|
||||
g. Click the API key card — verify inline form with provider dropdown and key input appears
|
||||
h. If any adapters are detected (Hermes, Claude Code), verify "detected" badges appear
|
||||
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>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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-04: Auto-detected tools show badges
|
||||
- CLOUD-05: API key entry for subscription providers
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Human approves the visual and functional verification
|
||||
- 4-step wizard works with all provider paths
|
||||
- No console errors in browser
|
||||
- Server logs show no unhandled exceptions
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-04-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue