diff --git a/.env.example b/.env.example index 6bf7704..05c1154 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ # Server-side only. Never expose real API keys in browser code. OPENAI_API_KEY= +# Optional local/dev token for provider="chatgpt-oauth" when no ChatGPT Action Authorization header is present. +CHATGPT_OAUTH_ACCESS_TOKEN= +# Optional override for OpenAI-compatible API gateways. Leave blank for https://api.openai.com/v1. +OPENAI_BASE_URL= +# Optional fallback provider for /api/generations when provider is set to "wavespeed". +WAVESPEED_API_KEY= DATABASE_URL= BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:5173 diff --git a/README.md b/README.md index a1b268f..8867084 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,12 @@ src/ server/ auth.ts Better Auth configuration db/ Neon Drizzle client and auth schema + generation/ GitHub source lookup, launch brief orchestration, and image provider adapters styles/ app.css Responsive product UI api/ auth/[...all].ts Vercel Function mounted at /api/auth/* + generations.ts Vercel Function mounted at /api/generations drizzle/ *.sql Database migrations public/ @@ -114,6 +116,12 @@ Secrets belong on the server, not in browser code: ```env OPENAI_API_KEY= +# Optional local/dev token for provider="chatgpt-oauth" when no ChatGPT Action Authorization header is present. +CHATGPT_OAUTH_ACCESS_TOKEN= +# Optional OpenAI-compatible gateway override. Leave blank for the official OpenAI API. +OPENAI_BASE_URL= +# Optional fallback provider key when /api/generations uses provider="wavespeed". +WAVESPEED_API_KEY= DATABASE_URL= BETTER_AUTH_SECRET= BETTER_AUTH_URL= @@ -147,14 +155,18 @@ http://localhost:5173/api/auth/callback/google https://your-domain.example/api/auth/callback/google ``` -For live generation, add a Node API layer or Vercel serverless route that: +## Live Generation API -1. fetches GitHub README / metadata / optional PDF context -2. calls the copy model -3. calls the image model -4. stores a manifest with copy, prompt, image path, model settings, and output preset +`POST /api/generations` runs the server-side launch-card workflow: -The current app keeps the core workflow deterministic and client-safe. +1. fetches GitHub repository metadata and README content, with deterministic fallback data for failed source lookups +2. calls OpenAI Responses for repository analysis and launch planning when `provider` is `chatgpt-oauth` or `openai` +3. calls OpenAI Images for the marketing card render and returns `outputs[locale].imageUrl` for browser preview +4. writes prompt, image, quality report, and manifest artifacts under the server output root + +The Hero generator sends `provider: "chatgpt-oauth"` by default, and the backend uses `chatgpt-oauth` when `provider` is omitted. For ChatGPT Action traffic, QuickFork reads the OAuth bearer token from the request `Authorization` header and uses it for repo analysis, launch planning, and image generation. For local development or server-side fallback, set `CHATGPT_OAUTH_ACCESS_TOKEN`. + +The API still accepts `provider: "openai"` with `OPENAI_API_KEY`, `provider: "mock"` for tests, and `provider: "wavespeed"` for the existing Wavespeed-compatible path. OAuth tokens are never written into manifests or API responses; model call metadata records only the credential source. ## Local Development @@ -193,10 +205,7 @@ VERCEL_PROJECT_ID= ## Roadmap -- Fetch GitHub README and repository metadata automatically. - Add PDF upload and extraction for paper-style repositories. -- Add server-side OpenAI generation using `OPENAI_API_KEY`. -- Save generation manifests for repeatable publishing. - Export README banners, slide images, and social cards as separate assets. - Add project pages for public showcases. diff --git a/api/generations.test.ts b/api/generations.test.ts index 41fa58a..7151f3e 100644 --- a/api/generations.test.ts +++ b/api/generations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { normalizeCreateGenerationInput } from "./generations.js"; +import { attachRequestAuth, normalizeCreateGenerationInput } from "./generations.js"; describe("/api/generations input contract", () => { it("normalizes a valid project launch generation request", () => { @@ -9,22 +9,22 @@ describe("/api/generations input contract", () => { repoUrl: " https://github.com/QwenLM/FlashQLA ", locales: ["en", "zh"], preset: "4:3", - provider: "mock", + provider: "chatgpt-oauth", imageQuality: "low", models: { - llm: "openai/gpt-5.5", - image: "openai/gpt-image-2/text-to-image", + llm: "gpt-5.5", + image: "gpt-image-2", }, }), ).toMatchObject({ repoUrl: "https://github.com/QwenLM/FlashQLA", locales: ["en", "zh"], preset: "4:3", - provider: "mock", + provider: "chatgpt-oauth", imageQuality: "low", models: { - llm: "openai/gpt-5.5", - image: "openai/gpt-image-2/text-to-image", + llm: "gpt-5.5", + image: "gpt-image-2", }, }); }); @@ -60,12 +60,48 @@ describe("/api/generations input contract", () => { ).toThrow(/locales/i); }); - it("uses the production provider requirement in validation errors", () => { - expect(() => + it("accepts ChatGPT OAuth, direct OpenAI, and Wavespeed providers while rejecting unknown providers", () => { + expect( + normalizeCreateGenerationInput({ + repoUrl: "https://github.com/QwenLM/FlashQLA", + provider: "chatgpt-oauth", + }).provider, + ).toBe("chatgpt-oauth"); + expect( normalizeCreateGenerationInput({ repoUrl: "https://github.com/QwenLM/FlashQLA", provider: "openai", + }).provider, + ).toBe("openai"); + expect( + normalizeCreateGenerationInput({ + repoUrl: "https://github.com/QwenLM/FlashQLA", + provider: "wavespeed", + }).provider, + ).toBe("wavespeed"); + expect(() => + normalizeCreateGenerationInput({ + repoUrl: "https://github.com/QwenLM/FlashQLA", + provider: "manual-oauth", }), - ).toThrow("provider must be wavespeed."); + ).toThrow("provider must be chatgpt-oauth, openai, wavespeed, or mock."); + }); + + it("attaches ChatGPT Action OAuth bearer tokens from the request header only", () => { + const normalized = normalizeCreateGenerationInput({ + repoUrl: "https://github.com/QwenLM/FlashQLA", + provider: "chatgpt-oauth", + auth: { + bearerToken: "body-token-must-not-be-trusted", + }, + } as unknown); + const withAuth = attachRequestAuth(normalized, "Bearer “oauth-user-token”"); + + expect(normalized.auth).toBeUndefined(); + expect(withAuth.auth).toEqual({ + bearerToken: "oauth-user-token", + source: "request_header", + }); + expect(JSON.stringify(withAuth)).not.toContain("Bearer"); }); }); diff --git a/api/generations.ts b/api/generations.ts index 792ac43..bc38c65 100644 --- a/api/generations.ts +++ b/api/generations.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { runProjectLaunchGeneration } from "../src/server/generation/orchestrator.js"; import type { CreateGenerationInput } from "../src/server/generation/types.js"; import { GenerationError } from "../src/server/generation/types.js"; +import { normalizeOpenAIApiKey } from "../src/server/generation/llm.js"; type ApiErrorCode = "VALIDATION_ERROR" | "METHOD_NOT_ALLOWED" | "GENERATION_FAILED"; const localeValues = ["en", "zh", "ja"] as const; @@ -18,7 +19,7 @@ const presetValues = [ "16:9", "21:9", ] as const; -const providerValues = ["mock", "wavespeed"] as const; +const providerValues = ["mock", "chatgpt-oauth", "openai", "wavespeed"] as const; const qualityValues = ["low"] as const; function sendJson(res: ServerResponse, statusCode: number, body: unknown) { @@ -67,7 +68,7 @@ export function normalizeCreateGenerationInput(body: unknown): CreateGenerationI if (!body || typeof body !== "object") { throw new GenerationError("VALIDATION_ERROR", "Request body must be a JSON object."); } - const value = body as Partial; + const { auth: _untrustedAuth, ...value } = body as Partial & { auth?: unknown }; if (typeof value.repoUrl !== "string" || !value.repoUrl.trim()) { throw new GenerationError("VALIDATION_ERROR", "repoUrl is required."); } @@ -80,7 +81,7 @@ export function normalizeCreateGenerationInput(body: unknown): CreateGenerationI throw new GenerationError("VALIDATION_ERROR", "preset is not supported."); } if (value.provider !== undefined && !isOneOf(value.provider, providerValues)) { - throw new GenerationError("VALIDATION_ERROR", "provider must be wavespeed."); + throw new GenerationError("VALIDATION_ERROR", "provider must be chatgpt-oauth, openai, wavespeed, or mock."); } if (value.imageQuality !== undefined && !isOneOf(value.imageQuality, qualityValues)) { throw new GenerationError("VALIDATION_ERROR", "imageQuality must be low."); @@ -98,6 +99,30 @@ export function normalizeCreateGenerationInput(body: unknown): CreateGenerationI } as CreateGenerationInput; } +function authorizationHeaderValue(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function bearerTokenFromAuthorizationHeader(value: string | string[] | undefined) { + const header = authorizationHeaderValue(value); + if (!header?.trim()) return undefined; + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match?.[1]) return undefined; + return normalizeOpenAIApiKey(match[1]); +} + +export function attachRequestAuth(input: CreateGenerationInput, authorizationHeader: string | string[] | undefined): CreateGenerationInput { + const bearerToken = bearerTokenFromAuthorizationHeader(authorizationHeader); + if (!bearerToken) return input; + return { + ...input, + auth: { + bearerToken, + source: "request_header", + }, + }; +} + export default async function handler(req: IncomingMessage, res: ServerResponse) { if (req.method !== "POST") { res.setHeader("Allow", "POST"); @@ -107,7 +132,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse) try { const body = await readJsonBody(req); - const input = normalizeCreateGenerationInput(body); + const input = attachRequestAuth(normalizeCreateGenerationInput(body), req.headers.authorization); const result = await runProjectLaunchGeneration(input); sendJson(res, 201, result); } catch (error) { diff --git a/src/App.test.tsx b/src/App.test.tsx index fd77503..74958e8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1357,18 +1357,18 @@ describe("App", () => { en: { promptPath: "output/project-launch/qwenlm-flashqla/en/marketing_card_prompt.txt", imagePath: "output/project-launch/qwenlm-flashqla/en/marketing-card.png", - imageUrl: "https://wavespeed.ai/generated/qwenlm-flashqla.png", + imageUrl: "data:image/png;base64,cXdlbmxtLWZsYXNocWxh", qualityReportPath: "output/project-launch/qwenlm-flashqla/en/quality-report.json", }, }, stages: [ { id: "repo", label: "Repository source", status: "completed" }, - { id: "readme", label: "GPT5.5 README analysis", status: "completed", model: "openai/gpt-5.5" }, - { id: "image", label: "gpt-image-2 render", status: "completed", model: "openai/gpt-image-2/text-to-image" }, + { id: "readme", label: "ChatGPT OAuth README analysis", status: "completed", model: "gpt-5.5" }, + { id: "image", label: "gpt-image-2 render", status: "completed", model: "gpt-image-2" }, ], modelCalls: [ - { provider: "wavespeed", model: "openai/gpt-5.5", purpose: "readme_analysis", status: "completed" }, - { provider: "wavespeed", model: "openai/gpt-image-2/text-to-image", purpose: "image_generation", status: "completed" }, + { provider: "chatgpt-oauth", model: "gpt-5.5", purpose: "readme_analysis", status: "completed" }, + { provider: "chatgpt-oauth", model: "gpt-image-2", purpose: "image_generation", status: "completed" }, ], launchBrief: { summary: "CUDA kernels for faster attention inference.", @@ -1562,19 +1562,19 @@ describe("App", () => { repoUrl: "https://github.com/QwenLM/FlashQLA", locales: ["en"], preset: "4:3", - provider: "wavespeed", + provider: "chatgpt-oauth", imageQuality: "low", }); expect(await screen.findByText(/generated gen_qwenlm_flashqla_test/i)).toBeInTheDocument(); const previewImage = await within(form).findByRole("img", { name: /qwenlm\/flashqla launch card/i }); const controls = form.querySelector(".referenceControls"); - expect(previewImage).toHaveAttribute("src", "https://wavespeed.ai/generated/qwenlm-flashqla.png"); - expect(previewImage.closest(".generationPreview")).toHaveAccessibleName("Generated Wavespeed image result"); + expect(previewImage).toHaveAttribute("src", "data:image/png;base64,cXdlbmxtLWZsYXNocWxh"); + expect(previewImage.closest(".generationPreview")).toHaveAccessibleName("Generated ChatGPT OAuth image result"); expect(controls).not.toBeNull(); expect((controls?.compareDocumentPosition(previewImage) ?? 0) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(within(form).getByRole("link", { name: /download generated image/i })).toHaveAttribute( "href", - "https://wavespeed.ai/generated/qwenlm-flashqla.png", + "data:image/png;base64,cXdlbmxtLWZsYXNocWxh", ); const briefRegion = await screen.findByRole("region", { name: /free repo launch brief/i }); expect(within(briefRegion).getByText(/AI project builders, open-source maintainers/i)).toBeInTheDocument(); @@ -1733,7 +1733,7 @@ describe("App", () => { const previewDialog = await screen.findByRole("dialog", { name: /generated image preview/i }); expect(within(previewDialog).getByRole("img", { name: /qwenlm\/flashqla launch card/i })).toHaveAttribute( "src", - "https://wavespeed.ai/generated/qwenlm-flashqla.png", + "data:image/png;base64,cXdlbmxtLWZsYXNocWxh", ); expect(within(previewDialog).getByRole("link", { name: /download generated image/i })).toHaveAttribute( "download", diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx index a04f7c1..158e226 100644 --- a/src/components/landing/HeroSection.tsx +++ b/src/components/landing/HeroSection.tsx @@ -208,7 +208,7 @@ async function createGeneration(input: { repoUrl: input.repoUrl, locales: input.locales, preset: input.preset, - provider: "wavespeed", + provider: "chatgpt-oauth", imageQuality: input.imageQuality, }), }); @@ -399,7 +399,7 @@ function ProjectLaunchInputPanel() { {generatedImageUrl ? ( -
+