From fa929f0a9e45f78e17b6012eb2fd279f879fab0f Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Wed, 22 Jul 2026 19:08:37 +0400 Subject: [PATCH] feat(orb): create APR repos under the submitting customer's own account Adds createAprRepoForCustomerSession, which creates a new GitHub repository via POST /user/repos using a specific customer session's own live OAuth token (getLiveSessionGitHubToken) -- never a fixed or operator session. GitHub always creates the repo under the authenticated user's own account, so the result is /, never a fixed owner. startGitHubWebOAuth now accepts an explicit scope parameter, defaulted to the existing "read:user" so every current caller is unaffected; only the APR idea-submission flow will pass "read:user repo". Closes #7637 --- src/auth/github-oauth.ts | 8 +- src/orb/apr-repo-creation.ts | 52 ++++++++++++ test/unit/auth.test.ts | 17 ++++ test/unit/orb-apr-repo-creation.test.ts | 102 ++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/orb/apr-repo-creation.ts create mode 100644 test/unit/orb-apr-repo-creation.test.ts diff --git a/src/auth/github-oauth.ts b/src/auth/github-oauth.ts index 8f47c4229f..5072a72c57 100644 --- a/src/auth/github-oauth.ts +++ b/src/auth/github-oauth.ts @@ -103,10 +103,16 @@ export async function pollGitHubDeviceFlow(env: Env, deviceCode: string) { ); } +/** + * Starts the GitHub web OAuth flow. `scope` defaults to `"read:user"` (the standard login flow) — pass + * `"read:user repo"` only for the explicit APR idea-submission variant (#7637) that needs to create a repo + * under the customer's own account later; every other caller keeps requesting `read:user` unchanged. + */ export async function startGitHubWebOAuth( env: Env, requestUrl: string, returnTo: string | undefined, + scope: string = "read:user", ): Promise<{ state: string; authorizationUrl: string; returnTo: string }> { if (!env.GITHUB_OAUTH_CLIENT_ID || !env.GITHUB_OAUTH_CLIENT_SECRET) throw new Error("github_oauth_not_configured"); const safeReturnTo = normalizeReturnTo(env, returnTo); @@ -118,7 +124,7 @@ export async function startGitHubWebOAuth( const authorizationUrl = new URL("https://github.com/login/oauth/authorize"); authorizationUrl.searchParams.set("client_id", env.GITHUB_OAUTH_CLIENT_ID); authorizationUrl.searchParams.set("redirect_uri", githubOAuthCallbackUrl(env, requestUrl)); - authorizationUrl.searchParams.set("scope", "read:user"); + authorizationUrl.searchParams.set("scope", scope); authorizationUrl.searchParams.set("state", state); return { state, authorizationUrl: authorizationUrl.toString(), returnTo: safeReturnTo }; } diff --git a/src/orb/apr-repo-creation.ts b/src/orb/apr-repo-creation.ts new file mode 100644 index 0000000000..0912cb2531 --- /dev/null +++ b/src/orb/apr-repo-creation.ts @@ -0,0 +1,52 @@ +// APR (auto-provisioned repo) creation under the submitting customer's own GitHub account (#7637, decision +// #7590 — corrected 2026-07-21). Earlier drafts of this issue specced creating the repo with a fixed/operator +// account's own token, which would put every APR repo under one owner regardless of who actually submitted the +// idea. That is NOT the intended behavior: the repo must be created under the CUSTOMER's own account, using +// THEIR OAuth authorization, via this codebase's existing multi-user session infrastructure +// (src/auth/github-oauth.ts) — never a fixed/operator session, never an installation-token driver. +// +// Requesting the `repo` scope only happens for the customer's own explicit idea-submission OAuth flow (the +// `scope` parameter `startGitHubWebOAuth` now accepts) — the default login flow is completely unaffected. + +import { getLiveSessionGitHubToken } from "../auth/github-oauth"; +import { githubHeaders, timeoutFetch } from "../github/client"; + +export type CreateAprRepoResult = + | { created: true; fullName: string; htmlUrl: string; nodeId: string } + | { created: false; status: number | null; error: string }; + +/** + * Create a new GitHub repository owned by the customer identified by `sessionId`, using THAT session's own + * live OAuth token (never a fixed/operator session) — GitHub's `POST /user/repos` always creates the repo + * under the authenticated user's own account, so the returned `full_name` is `/`. + * + * Returns a structured `{ created: false }` result rather than throwing on a missing/expired session token or + * a GitHub API error (e.g. a repo-name collision), so callers get a total function they can branch on. + */ +export async function createAprRepoForCustomerSession( + env: Env, + sessionId: string, + repoName: string, + options: { private?: boolean; description?: string } = {}, +): Promise { + const token = await getLiveSessionGitHubToken(env, sessionId); + if (!token) return { created: false, status: null, error: "customer_session_token_unavailable" }; + + const body: Record = { name: repoName, private: options.private ?? true }; + if (options.description) body.description = options.description; + + const response = await timeoutFetch("https://api.github.com/user/repos", { + method: "POST", + headers: githubHeaders({ token, json: true }), + body: JSON.stringify(body), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + return { created: false, status: response.status, error: detail.slice(0, 200) || `repo creation failed (${response.status})` }; + } + const payload = (await response.json().catch(() => null)) as { full_name?: string; html_url?: string; node_id?: string } | null; + if (!payload?.full_name || !payload.html_url || !payload.node_id) { + return { created: false, status: response.status, error: "repo creation response missing required fields" }; + } + return { created: true, fullName: payload.full_name, htmlUrl: payload.html_url, nodeId: payload.node_id }; +} diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index e1fa88e594..4917dd6032 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -854,6 +854,9 @@ describe("private-beta auth and rate limiting", () => { expect(started.authorizationUrl).toContain("https://github.com/login/oauth/authorize"); expect(started.authorizationUrl).toContain("client_id=client-id"); expect(started.authorizationUrl).toContain("redirect_uri=https%3A%2F%2Fapi.loopover.ai%2Fv1%2Fauth%2Fgithub%2Fcallback"); + // Default login flow requests only read:user -- unaffected by the #7637 scope parameter. + expect(started.authorizationUrl).toContain("scope=read%3Auser"); + expect(started.authorizationUrl).not.toContain("repo"); await expect( startGitHubWebOAuth(createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" }), "https://loopover-api.aethereal.dev/v1/auth/github/start", undefined), @@ -895,6 +898,20 @@ describe("private-beta auth and rate limiting", () => { ).rejects.toThrow(/bad code/); }); + it("requests the repo scope only for the explicit APR idea-submission variant (#7637)", async () => { + const env = createTestEnv({ + GITHUB_OAUTH_CLIENT_ID: "client-id", + GITHUB_OAUTH_CLIENT_SECRET: "client-secret", + }); + const started = await startGitHubWebOAuth( + env, + "https://loopover-api.aethereal.dev/v1/auth/github/start", + "https://loopover.ai/app/workbench", + "read:user repo", + ); + expect(started.authorizationUrl).toContain("scope=read%3Auser+repo"); + }); + it("normalizes GitHub web OAuth fallbacks and rejects malformed callback state", async () => { const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", diff --git a/test/unit/orb-apr-repo-creation.test.ts b/test/unit/orb-apr-repo-creation.test.ts new file mode 100644 index 0000000000..64c1785855 --- /dev/null +++ b/test/unit/orb-apr-repo-creation.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getLiveSessionGitHubToken } from "../../src/auth/github-oauth"; +import { createAprRepoForCustomerSession } from "../../src/orb/apr-repo-creation"; +import { createTestEnv } from "../helpers/d1"; + +// Mock the session-token lookup so no real session/DB state is needed. The mocked value is an opaque, +// obviously-fake placeholder — never a PEM/private-key-shaped fixture (a prior attempt at a sibling APR +// module was auto-closed by the secret scanner for exactly that). +vi.mock("../../src/auth/github-oauth", async (importOriginal) => ({ + ...(await importOriginal()), + getLiveSessionGitHubToken: vi.fn(), +})); +const mockedToken = vi.mocked(getLiveSessionGitHubToken); + +/** Capture the outbound request so we can assert the endpoint, method, auth, and body. */ +function stubFetch(handler: (url: string, init: RequestInit) => Response): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init ?? {})); +} + +describe("createAprRepoForCustomerSession (#7637)", () => { + beforeEach(() => { + mockedToken.mockReset(); + mockedToken.mockResolvedValue("gho_customer_session_token"); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("POSTs to /user/repos with the customer session's own token, defaulting to private", async () => { + let seenUrl = ""; + let seenInit: RequestInit = {}; + stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return new Response( + JSON.stringify({ full_name: "joesmoe/widgets", html_url: "https://github.com/joesmoe/widgets", node_id: "R_abc123" }), + { status: 201 }, + ); + }); + + const env = createTestEnv(); + const result = await createAprRepoForCustomerSession(env, "session-1", "widgets"); + + expect(mockedToken).toHaveBeenCalledWith(env, "session-1"); + expect(seenUrl).toBe("https://api.github.com/user/repos"); + expect(seenInit.method).toBe("POST"); + expect((seenInit.headers as Record).authorization).toBe("Bearer gho_customer_session_token"); + expect(JSON.parse(String(seenInit.body))).toEqual({ name: "widgets", private: true }); + expect(result).toEqual({ + created: true, + fullName: "joesmoe/widgets", + htmlUrl: "https://github.com/joesmoe/widgets", + nodeId: "R_abc123", + }); + }); + + it("passes through an explicit private:false and an optional description", async () => { + let seenInit: RequestInit = {}; + stubFetch((_url, init) => { + seenInit = init; + return new Response( + JSON.stringify({ full_name: "joesmoe/widgets", html_url: "https://github.com/joesmoe/widgets", node_id: "R_abc123" }), + { status: 201 }, + ); + }); + + await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets", { private: false, description: "A widget repo" }); + + expect(JSON.parse(String(seenInit.body))).toEqual({ name: "widgets", private: false, description: "A widget repo" }); + }); + + it("fails closed without calling GitHub when the customer session has no live token", async () => { + mockedToken.mockResolvedValue(null); + const calls: string[] = []; + stubFetch((url) => { + calls.push(url); + return new Response("", { status: 200 }); + }); + + const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets"); + + expect(result).toEqual({ created: false, status: null, error: "customer_session_token_unavailable" }); + expect(calls).toEqual([]); + }); + + it("returns a structured failure on a GitHub API error (e.g. a repo-name collision) without throwing", async () => { + stubFetch(() => new Response("Repository creation failed.", { status: 422 })); + + const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets"); + + expect(result).toEqual({ created: false, status: 422, error: "Repository creation failed." }); + }); + + it("fails closed when GitHub returns 2xx but the payload is missing required fields", async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 201 })); + + const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets"); + + expect(result).toEqual({ created: false, status: 201, error: "repo creation response missing required fields" }); + }); +});