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>
10 KiB
10 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 31-puter.js-zero-config-cloud | 02 | execute | 1 |
|
true |
|
|
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
<execution_context> @/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md @/home/mikkel/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md From server/src/services/secrets.ts: ```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:
export function assertBoard(req): void;
export function assertCompanyAccess(req, companyId): void;
From server/src/app.ts (route mounting):
// Unauthenticated routes mounted before api Router:
app.use("/api", hardwareRoutes()); // line 132
// Authenticated routes inside api Router:
api.use(secretRoutes(db)); // line 157
Task 1: googleOAuthService — PKCE generation, code exchange, token storage
server/src/services/google-oauth.ts
server/src/services/secrets.ts,
server/src/routes/secrets.ts
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.
cd /opt/nexus && grep -c "generatePkce\|exchangeCode\|storeTokens\|resolveTokens\|generateAuthUrl" server/src/services/google-oauth.ts
- 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
googleOAuthService generates PKCE auth URLs, exchanges codes for tokens, and stores/retrieves tokens via secretService
Task 2: googleOAuthRoutes + API key storage route + mount in app.ts
server/src/routes/google-oauth.ts,
server/src/app.ts
server/src/app.ts,
server/src/routes/secrets.ts,
server/src/routes/authz.ts
**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).
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
- 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
Google OAuth PKCE routes handle authorize + callback flow, API key storage route accepts provider/key pairs, both mounted in app.ts with board auth
- `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
<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>