Skip to content

Commit fa929f0

Browse files
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 <customer-login>/<repoName>, 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
1 parent b3bd0ba commit fa929f0

4 files changed

Lines changed: 178 additions & 1 deletion

File tree

src/auth/github-oauth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,16 @@ export async function pollGitHubDeviceFlow(env: Env, deviceCode: string) {
103103
);
104104
}
105105

106+
/**
107+
* Starts the GitHub web OAuth flow. `scope` defaults to `"read:user"` (the standard login flow) — pass
108+
* `"read:user repo"` only for the explicit APR idea-submission variant (#7637) that needs to create a repo
109+
* under the customer's own account later; every other caller keeps requesting `read:user` unchanged.
110+
*/
106111
export async function startGitHubWebOAuth(
107112
env: Env,
108113
requestUrl: string,
109114
returnTo: string | undefined,
115+
scope: string = "read:user",
110116
): Promise<{ state: string; authorizationUrl: string; returnTo: string }> {
111117
if (!env.GITHUB_OAUTH_CLIENT_ID || !env.GITHUB_OAUTH_CLIENT_SECRET) throw new Error("github_oauth_not_configured");
112118
const safeReturnTo = normalizeReturnTo(env, returnTo);
@@ -118,7 +124,7 @@ export async function startGitHubWebOAuth(
118124
const authorizationUrl = new URL("https://github.com/login/oauth/authorize");
119125
authorizationUrl.searchParams.set("client_id", env.GITHUB_OAUTH_CLIENT_ID);
120126
authorizationUrl.searchParams.set("redirect_uri", githubOAuthCallbackUrl(env, requestUrl));
121-
authorizationUrl.searchParams.set("scope", "read:user");
127+
authorizationUrl.searchParams.set("scope", scope);
122128
authorizationUrl.searchParams.set("state", state);
123129
return { state, authorizationUrl: authorizationUrl.toString(), returnTo: safeReturnTo };
124130
}

src/orb/apr-repo-creation.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// APR (auto-provisioned repo) creation under the submitting customer's own GitHub account (#7637, decision
2+
// #7590 — corrected 2026-07-21). Earlier drafts of this issue specced creating the repo with a fixed/operator
3+
// account's own token, which would put every APR repo under one owner regardless of who actually submitted the
4+
// idea. That is NOT the intended behavior: the repo must be created under the CUSTOMER's own account, using
5+
// THEIR OAuth authorization, via this codebase's existing multi-user session infrastructure
6+
// (src/auth/github-oauth.ts) — never a fixed/operator session, never an installation-token driver.
7+
//
8+
// Requesting the `repo` scope only happens for the customer's own explicit idea-submission OAuth flow (the
9+
// `scope` parameter `startGitHubWebOAuth` now accepts) — the default login flow is completely unaffected.
10+
11+
import { getLiveSessionGitHubToken } from "../auth/github-oauth";
12+
import { githubHeaders, timeoutFetch } from "../github/client";
13+
14+
export type CreateAprRepoResult =
15+
| { created: true; fullName: string; htmlUrl: string; nodeId: string }
16+
| { created: false; status: number | null; error: string };
17+
18+
/**
19+
* Create a new GitHub repository owned by the customer identified by `sessionId`, using THAT session's own
20+
* live OAuth token (never a fixed/operator session) — GitHub's `POST /user/repos` always creates the repo
21+
* under the authenticated user's own account, so the returned `full_name` is `<their-login>/<repoName>`.
22+
*
23+
* Returns a structured `{ created: false }` result rather than throwing on a missing/expired session token or
24+
* a GitHub API error (e.g. a repo-name collision), so callers get a total function they can branch on.
25+
*/
26+
export async function createAprRepoForCustomerSession(
27+
env: Env,
28+
sessionId: string,
29+
repoName: string,
30+
options: { private?: boolean; description?: string } = {},
31+
): Promise<CreateAprRepoResult> {
32+
const token = await getLiveSessionGitHubToken(env, sessionId);
33+
if (!token) return { created: false, status: null, error: "customer_session_token_unavailable" };
34+
35+
const body: Record<string, unknown> = { name: repoName, private: options.private ?? true };
36+
if (options.description) body.description = options.description;
37+
38+
const response = await timeoutFetch("https://api.github.com/user/repos", {
39+
method: "POST",
40+
headers: githubHeaders({ token, json: true }),
41+
body: JSON.stringify(body),
42+
});
43+
if (!response.ok) {
44+
const detail = await response.text().catch(() => "");
45+
return { created: false, status: response.status, error: detail.slice(0, 200) || `repo creation failed (${response.status})` };
46+
}
47+
const payload = (await response.json().catch(() => null)) as { full_name?: string; html_url?: string; node_id?: string } | null;
48+
if (!payload?.full_name || !payload.html_url || !payload.node_id) {
49+
return { created: false, status: response.status, error: "repo creation response missing required fields" };
50+
}
51+
return { created: true, fullName: payload.full_name, htmlUrl: payload.html_url, nodeId: payload.node_id };
52+
}

test/unit/auth.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,9 @@ describe("private-beta auth and rate limiting", () => {
854854
expect(started.authorizationUrl).toContain("https://github.com/login/oauth/authorize");
855855
expect(started.authorizationUrl).toContain("client_id=client-id");
856856
expect(started.authorizationUrl).toContain("redirect_uri=https%3A%2F%2Fapi.loopover.ai%2Fv1%2Fauth%2Fgithub%2Fcallback");
857+
// Default login flow requests only read:user -- unaffected by the #7637 scope parameter.
858+
expect(started.authorizationUrl).toContain("scope=read%3Auser");
859+
expect(started.authorizationUrl).not.toContain("repo");
857860

858861
await expect(
859862
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", () => {
895898
).rejects.toThrow(/bad code/);
896899
});
897900

901+
it("requests the repo scope only for the explicit APR idea-submission variant (#7637)", async () => {
902+
const env = createTestEnv({
903+
GITHUB_OAUTH_CLIENT_ID: "client-id",
904+
GITHUB_OAUTH_CLIENT_SECRET: "client-secret",
905+
});
906+
const started = await startGitHubWebOAuth(
907+
env,
908+
"https://loopover-api.aethereal.dev/v1/auth/github/start",
909+
"https://loopover.ai/app/workbench",
910+
"read:user repo",
911+
);
912+
expect(started.authorizationUrl).toContain("scope=read%3Auser+repo");
913+
});
914+
898915
it("normalizes GitHub web OAuth fallbacks and rejects malformed callback state", async () => {
899916
const env = createTestEnv({
900917
GITHUB_OAUTH_CLIENT_ID: "client-id",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import { getLiveSessionGitHubToken } from "../../src/auth/github-oauth";
4+
import { createAprRepoForCustomerSession } from "../../src/orb/apr-repo-creation";
5+
import { createTestEnv } from "../helpers/d1";
6+
7+
// Mock the session-token lookup so no real session/DB state is needed. The mocked value is an opaque,
8+
// obviously-fake placeholder — never a PEM/private-key-shaped fixture (a prior attempt at a sibling APR
9+
// module was auto-closed by the secret scanner for exactly that).
10+
vi.mock("../../src/auth/github-oauth", async (importOriginal) => ({
11+
...(await importOriginal<typeof import("../../src/auth/github-oauth")>()),
12+
getLiveSessionGitHubToken: vi.fn(),
13+
}));
14+
const mockedToken = vi.mocked(getLiveSessionGitHubToken);
15+
16+
/** Capture the outbound request so we can assert the endpoint, method, auth, and body. */
17+
function stubFetch(handler: (url: string, init: RequestInit) => Response): void {
18+
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init ?? {}));
19+
}
20+
21+
describe("createAprRepoForCustomerSession (#7637)", () => {
22+
beforeEach(() => {
23+
mockedToken.mockReset();
24+
mockedToken.mockResolvedValue("gho_customer_session_token");
25+
});
26+
afterEach(() => {
27+
vi.unstubAllGlobals();
28+
});
29+
30+
it("POSTs to /user/repos with the customer session's own token, defaulting to private", async () => {
31+
let seenUrl = "";
32+
let seenInit: RequestInit = {};
33+
stubFetch((url, init) => {
34+
seenUrl = url;
35+
seenInit = init;
36+
return new Response(
37+
JSON.stringify({ full_name: "joesmoe/widgets", html_url: "https://github.com/joesmoe/widgets", node_id: "R_abc123" }),
38+
{ status: 201 },
39+
);
40+
});
41+
42+
const env = createTestEnv();
43+
const result = await createAprRepoForCustomerSession(env, "session-1", "widgets");
44+
45+
expect(mockedToken).toHaveBeenCalledWith(env, "session-1");
46+
expect(seenUrl).toBe("https://api.github.com/user/repos");
47+
expect(seenInit.method).toBe("POST");
48+
expect((seenInit.headers as Record<string, string>).authorization).toBe("Bearer gho_customer_session_token");
49+
expect(JSON.parse(String(seenInit.body))).toEqual({ name: "widgets", private: true });
50+
expect(result).toEqual({
51+
created: true,
52+
fullName: "joesmoe/widgets",
53+
htmlUrl: "https://github.com/joesmoe/widgets",
54+
nodeId: "R_abc123",
55+
});
56+
});
57+
58+
it("passes through an explicit private:false and an optional description", async () => {
59+
let seenInit: RequestInit = {};
60+
stubFetch((_url, init) => {
61+
seenInit = init;
62+
return new Response(
63+
JSON.stringify({ full_name: "joesmoe/widgets", html_url: "https://github.com/joesmoe/widgets", node_id: "R_abc123" }),
64+
{ status: 201 },
65+
);
66+
});
67+
68+
await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets", { private: false, description: "A widget repo" });
69+
70+
expect(JSON.parse(String(seenInit.body))).toEqual({ name: "widgets", private: false, description: "A widget repo" });
71+
});
72+
73+
it("fails closed without calling GitHub when the customer session has no live token", async () => {
74+
mockedToken.mockResolvedValue(null);
75+
const calls: string[] = [];
76+
stubFetch((url) => {
77+
calls.push(url);
78+
return new Response("", { status: 200 });
79+
});
80+
81+
const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets");
82+
83+
expect(result).toEqual({ created: false, status: null, error: "customer_session_token_unavailable" });
84+
expect(calls).toEqual([]);
85+
});
86+
87+
it("returns a structured failure on a GitHub API error (e.g. a repo-name collision) without throwing", async () => {
88+
stubFetch(() => new Response("Repository creation failed.", { status: 422 }));
89+
90+
const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets");
91+
92+
expect(result).toEqual({ created: false, status: 422, error: "Repository creation failed." });
93+
});
94+
95+
it("fails closed when GitHub returns 2xx but the payload is missing required fields", async () => {
96+
stubFetch(() => new Response(JSON.stringify({}), { status: 201 }));
97+
98+
const result = await createAprRepoForCustomerSession(createTestEnv(), "session-1", "widgets");
99+
100+
expect(result).toEqual({ created: false, status: 201, error: "repo creation response missing required fields" });
101+
});
102+
});

0 commit comments

Comments
 (0)