272 lines
15 KiB
Markdown
272 lines
15 KiB
Markdown
---
|
|
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/__tests__/31-google-oauth.test.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 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, resolveTokens methods"
|
|
exports: ["googleOAuthService"]
|
|
- path: "server/src/routes/google-oauth.ts"
|
|
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"
|
|
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"
|
|
- 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 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, 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>
|
|
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 (pendingTokens pattern) + API key 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`.
|
|
|
|
**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).
|
|
</action>
|
|
<verify>
|
|
<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 "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 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 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 /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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/31-puter.js-zero-config-cloud/31-02-SUMMARY.md`
|
|
</output>
|