15 KiB
15 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, unit tests, app.ts wiring
<execution_context> @/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md @/home/mikkel/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.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 (pendingTokens pattern) + API key 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`.
**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)`. 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 `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 })`.
**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, claim, 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\|oauth/google/claim\|api-keys/store" server/src/routes/google-oauth.ts
- grep -q "googleOAuthRoutes" server/src/routes/google-oauth.ts
- grep -q "oauth/google/authorize" server/src/routes/google-oauth.ts
- grep -q "oauth/google/callback" server/src/routes/google-oauth.ts
- grep -q "oauth/google/claim" server/src/routes/google-oauth.ts
- grep -q "pendingTokens" server/src/routes/google-oauth.ts
- grep -q "pendingPkce" server/src/routes/google-oauth.ts
- grep -q "api-keys/store" server/src/routes/google-oauth.ts
- grep -q "assertBoard" server/src/routes/google-oauth.ts
- grep -q "stateId" server/src/routes/google-oauth.ts
- grep -q "import.*googleOAuthRoutes" server/src/app.ts
- grep -q "api.use(googleOAuthRoutes" server/src/app.ts
- NO grep match for "companyId" in pendingPkce — companyId must NOT be stored at authorize time
Google OAuth routes use the pendingTokens pattern: authorize stores PKCE verifier only (no companyId), callback exchanges code and parks tokens by stateId, claim endpoint links tokens to companyId after company creation. API key storage route supports OpenAI/Anthropic/Groq key entry. All mounted in app.ts.
Task 3: Unit tests for Google OAuth service and routes
server/src/__tests__/31-google-oauth.test.ts
server/src/services/google-oauth.ts,
server/src/routes/google-oauth.ts,
server/src/__tests__/31-puter-proxy.test.ts
- Test 1: generatePkce produces verifier (43 chars base64url) and challenge (43 chars base64url)
- Test 2: generateAuthUrl returns URL containing client_id, code_challenge, code_challenge_method=S256, state
- Test 3: exchangeCode POSTs to Google token endpoint with correct body (grant_type, code_verifier)
- Test 4: storeTokens creates a new secret when none exists (name: "google_gemini_oauth_token")
- Test 5: storeTokens rotates when secret already exists
- Test 6: POST /oauth/google/authorize returns { url, stateId } and stores verifier in pendingPkce (no companyId)
- Test 7: GET /oauth/google/callback with valid state exchanges code, stores tokens in pendingTokens, redirects to /?google_oauth=success
- Test 8: GET /oauth/google/callback with invalid state returns 400
- Test 9: POST /oauth/google/claim with valid stateId moves tokens to secretService and returns { ok: true }
- Test 10: POST /oauth/google/claim with expired/missing stateId returns 404
- Test 11: POST /api-keys/store stores key via secretService upsert pattern
Create `server/src/__tests__/31-google-oauth.test.ts` using vitest.
Mock `fetch` globally for Google token endpoint calls. Create a mock db object that stubs secretService behavior (getByName, create, rotate, resolveSecretValue).
For service tests: import `googleOAuthService` directly and test each method.
For route tests: use supertest with a minimal Express app that mounts `googleOAuthRoutes(mockDb)`. Test the full authorize -> callback -> claim flow in sequence (one test that exercises all three endpoints). Also test error cases (invalid state, expired state).
Import patterns: `import { googleOAuthService } from "../services/google-oauth.js"` and `import { googleOAuthRoutes } from "../routes/google-oauth.js"`.
Follow the same test file structure as `31-puter-proxy.test.ts` (from Task 1 of Plan 01).
cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-google-oauth.test.ts
All 11 Google OAuth tests pass, covering PKCE generation, token exchange, the pendingTokens claim flow, and API key storage
- `cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-google-oauth.test.ts` — all tests pass
- `cd /opt/nexus && pnpm --filter @paperclipai/server exec tsc --noEmit` — no TypeScript errors
- grep confirms all routes and services exist with correct exports
- Google OAuth callback uses pendingTokens pattern (no companyId at callback time)
- Claim endpoint properly links tokens to companyId
<success_criteria>
- googleOAuthService generates PKCE auth URLs, exchanges codes, stores tokens
- 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>