Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13993,7 +13993,7 @@
],
"responses": {
"200": {
"description": "Public GitHub repository stars/forks for the website chrome; any syntactically-valid owner/repo is accepted unless the deployment configures PUBLIC_REPO_STATS_ALLOWLIST.",
"description": "Public GitHub repository stars/forks for the website chrome; PUBLIC_REPO_STATS_ALLOWLIST must explicitly include the owner/repo.",
"content": {
"application/json": {
"schema": {
Expand Down
10 changes: 5 additions & 5 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,11 @@ declare global {
GITTENSOR_REGISTRY_URL: string;
GITHUB_PUBLIC_TOKEN?: string;
/** Public repo-stats endpoint (#4612): comma-separated allowlist of "owner/repo" pairs (case-insensitive)
* permitted to query GET /v1/public/github/repos/:owner/:repo/stats. Default "" (unset) => allow any
* syntactically-valid owner/repo -- this route proxies the live GitHub API rather than gittensory's own
* installation DB, so (unlike the sibling badge.svg route) there is no "installed" gate to fall back on
* instead. Set this to restrict a deployment to specific repos. Mirrors GITTENSORY_PUBLIC_STATS_REPOS's
* comma-separated shape (src/review/public-stats.ts). See src/github/public.ts. */
* permitted to query GET /v1/public/github/repos/:owner/:repo/stats. Default "" (unset) denies all repos
* because this unauthenticated route proxies the live GitHub API with the deployment's server-side token.
* Set this to the exact public repos that should expose website chrome stats. Mirrors
* GITTENSORY_PUBLIC_STATS_REPOS's comma-separated shape (src/review/public-stats.ts). See
* src/github/public.ts. */
PUBLIC_REPO_STATS_ALLOWLIST?: string;
/** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */
SCORING_TIME_DECAY_ENABLED?: string;
Expand Down
8 changes: 4 additions & 4 deletions src/github/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ export function clearPublicRepoStatsCacheForTests(): void {
}

// Parses PUBLIC_REPO_STATS_ALLOWLIST into a lowercased "owner/repo" set, mirroring publicStatsProjects's
// comma-separated parsing in src/review/public-stats.ts. Empty/unset -> empty set -> no allowlist restriction
// (#4612): a self-hosted fork's own repo works out of the box, with no code change, exactly like the sibling
// badge.svg route already does for any installed repo.
// comma-separated parsing in src/review/public-stats.ts. Empty/unset -> empty set -> deny all public
// repo-stats requests so the unauthenticated route cannot proxy arbitrary GitHub API calls with the
// deployment's server-side token.
function publicRepoStatsAllowlist(env: Pick<Env, "PUBLIC_REPO_STATS_ALLOWLIST">): Set<string> {
const allowlist = new Set<string>();
for (const entry of (env.PUBLIC_REPO_STATS_ALLOWLIST ?? "").split(",")) {
Expand All @@ -180,7 +180,7 @@ function publicRepoFullName(env: Pick<Env, "PUBLIC_REPO_STATS_ALLOWLIST">, owner
const normalizedRepoName = repoName.toLowerCase();
const normalizedFullName = `${normalizedOwnerName}/${normalizedRepoName}`;
const allowlist = publicRepoStatsAllowlist(env);
if (allowlist.size > 0 && !allowlist.has(normalizedFullName)) throw new Error("invalid_github_repo");
if (!allowlist.has(normalizedFullName)) throw new Error("invalid_github_repo");
return normalizedFullName;
}

Expand Down
2 changes: 1 addition & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export function buildOpenApiSpec() {
path: "/v1/public/github/repos/{owner}/{repo}/stats",
request: { params: z.object({ owner: z.string(), repo: z.string() }) },
responses: {
200: { description: "Public GitHub repository stars/forks for the website chrome; any syntactically-valid owner/repo is accepted unless the deployment configures PUBLIC_REPO_STATS_ALLOWLIST.", content: { "application/json": { schema: PublicRepoStatsSchema } } },
200: { description: "Public GitHub repository stars/forks for the website chrome; PUBLIC_REPO_STATS_ALLOWLIST must explicitly include the owner/repo.", content: { "application/json": { schema: PublicRepoStatsSchema } } },
400: { description: "Invalid or non-allowlisted GitHub repository" },
503: { description: "GitHub repository stats are unavailable" },
},
Expand Down
35 changes: 13 additions & 22 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe("api routes", () => {

it("serves public GitHub repo stats without relying on browser GitHub quota", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory" });
const calls: Array<{ url: string; authorization?: string }> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
Expand Down Expand Up @@ -158,8 +158,10 @@ describe("api routes", () => {
it("REGRESSION (#regression-safe-propagation): serves public GitHub repo stats when GITHUB_PUBLIC_TOKEN is unset, still unauthenticated (self-host operators can run without it)", async () => {
const app = createApp();
// No GITHUB_PUBLIC_TOKEN at all -- exercises fetchRepoStatsFromGitHub's admission-key "token absent" branch,
// distinct from the "token present" branch every other stats test in this file exercises.
const env = createTestEnv({});
// distinct from the "token present" branch every other stats test in this file exercises. The allowlist
// entry is required independently of that concern (an unset PUBLIC_REPO_STATS_ALLOWLIST now denies all
// repos by default) so this test keeps isolating the token-absent behavior it's actually regression-testing.
const env = createTestEnv({ PUBLIC_REPO_STATS_ALLOWLIST: "acme/anon" });
const calls: Array<{ url: string; authorization: string | null }> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const authorization = new Headers(init?.headers).get("authorization");
Expand All @@ -173,27 +175,16 @@ describe("api routes", () => {
expect(calls).toEqual([{ url: "https://api.github.com/repos/acme/anon", authorization: null }]);
});

it("serves public GitHub repo stats for any repo when PUBLIC_REPO_STATS_ALLOWLIST is unset (#4612)", async () => {
it("rejects public GitHub repo stats when PUBLIC_REPO_STATS_ALLOWLIST is unset", async () => {
const app = createApp();
// No PUBLIC_REPO_STATS_ALLOWLIST override: a self-hoster's own fork must work without code changes.
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
const calls: string[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
calls.push(input.toString());
return Response.json({ full_name: "acme/widgets", html_url: "https://github.com/acme/widgets", stargazers_count: 5, forks_count: 1 });
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

const response = await app.request("/v1/public/github/repos/acme/widgets/stats", {}, env);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
repoFullName: "acme/widgets",
htmlUrl: "https://github.com/acme/widgets",
stargazers_count: 5,
forks_count: 1,
source: "github",
stale: false,
});
expect(calls).toEqual(["https://api.github.com/repos/acme/widgets"]);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: "invalid_github_repo" });
expect(fetchMock).not.toHaveBeenCalled();
});

it("rejects public GitHub repo stats for repos outside a configured PUBLIC_REPO_STATS_ALLOWLIST, tolerating whitespace/casing/empty entries (#4612)", async () => {
Expand Down Expand Up @@ -223,7 +214,7 @@ describe("api routes", () => {

it("normalizes allowlisted public GitHub repo stats casing before fetching and caching", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory" });
const calls: string[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
calls.push(input.toString());
Expand All @@ -250,7 +241,7 @@ describe("api routes", () => {

it("serves stale public repo stats instead of failing during transient GitHub errors", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory" });
vi.stubGlobal("fetch", async () => Response.json({ full_name: "JSONbored/gittensory", html_url: "https://github.com/JSONbored/gittensory", stargazers_count: 21, forks_count: 4 }));

const fresh = await app.request("/v1/public/github/repos/JSONbored/gittensory/stats", {}, env);
Expand Down
2 changes: 2 additions & 0 deletions worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface __BaseEnv_Env {
GITTENSORY_REVIEW_REPOS: "";
GITTENSORY_PUBLIC_STATS: "true";
GITTENSORY_PUBLIC_STATS_REPOS: "JSONbored/gittensory,JSONbored/awesome-claude,JSONbored/metagraphed";
PUBLIC_REPO_STATS_ALLOWLIST: "JSONbored/gittensory";
GITTENSORY_DUPLICATE_WINNER: "true";
GITTENSORY_OPEN_PR_FILE_COLLISION: "true";
GITTENSORY_SKIP_AUTOMATION_BOT_PRS: "true";
Expand Down Expand Up @@ -103,6 +104,7 @@ declare namespace NodeJS {
| "GITTENSORY_SKIP_AUTOMATION_BOT_PRS"
| "GITTENSORY_SWEEP_WATCHDOG"
| "PUBLIC_API_ORIGIN"
| "PUBLIC_REPO_STATS_ALLOWLIST"
| "PUBLIC_SITE_ORIGIN"
| "SCORING_TIME_DECAY_ENABLED"
>> {}
Expand Down
3 changes: 3 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@
// src/env.d.ts for the full split rationale). These three repos' historical audit_events rows remain real and
// safe to publish even though the cutover allowlist above no longer lists them.
"GITTENSORY_PUBLIC_STATS_REPOS": "JSONbored/gittensory,JSONbored/awesome-claude,JSONbored/metagraphed",
// Website chrome repo stats are public and proxied through the server-side GitHub token, so require
// an explicit narrow allowlist instead of letting unset config proxy arbitrary owner/repo pairs.
"PUBLIC_REPO_STATS_ALLOWLIST": "JSONbored/gittensory",
// Duplicate-winner adjudication (#dup-winner): when a same-issue cluster of OPEN PRs forms, spare exactly
// ONE winner — the lowest-numbered (earliest opened) open PR — instead of blocking/closing every sibling.
// Only the LOSERS get the duplicate blocker + close reason + slop penalty + panel block; the winner is
Expand Down
Loading