fix: use GitHub App auth for GitHub account lookups

* fix: use GitHub App auth for GitHub account lookups

* style: format GitHub account age backfill
This commit is contained in:
Patrick Erichsen
2026-06-03 00:24:23 -07:00
committed by GitHub
parent 0a79612fe5
commit 953358a322
12 changed files with 456 additions and 144 deletions
+2
View File
@@ -59,6 +59,7 @@ import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
import type * as lib_githubAuth from "../lib/githubAuth.js";
import type * as lib_githubBackup from "../lib/githubBackup.js";
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
import type * as lib_githubImport from "../lib/githubImport.js";
@@ -200,6 +201,7 @@ declare const fullApi: ApiFromModules<{
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
"lib/githubAuth": typeof lib_githubAuth;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubIdentity": typeof lib_githubIdentity;
"lib/githubImport": typeof lib_githubImport;
+5 -1
View File
@@ -179,7 +179,11 @@ export const backfillGitHubCreatedAtInternal = internalAction({
handles: v.optional(v.array(v.string())),
},
handler: async (ctx: ActionCtx, args): Promise<BackfillResult> => {
const batchSize = clampPositiveInteger(args.batchSize, DEFAULT_BATCH_SIZE, MAX_ACTION_BATCH_SIZE);
const batchSize = clampPositiveInteger(
args.batchSize,
DEFAULT_BATCH_SIZE,
MAX_ACTION_BATCH_SIZE,
);
const maxPages = clampPositiveInteger(args.maxPages, DEFAULT_MAX_PAGES, MAX_MAX_PAGES);
const dryRun = args.dryRun ?? false;
const fetchedAt = Date.now();
+9 -7
View File
@@ -282,10 +282,10 @@ describe("requireGitHubAccountAge", () => {
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/12345",
expect.objectContaining({
headers: {
headers: expect.objectContaining({
"User-Agent": "clawhub",
Authorization: "Bearer ghp_test123",
},
}),
}),
);
});
@@ -318,9 +318,10 @@ describe("requireGitHubAccountAge", () => {
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/12345",
expect.objectContaining({
headers: { "User-Agent": "clawhub" },
headers: expect.objectContaining({ "User-Agent": "clawhub" }),
}),
);
expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("Authorization");
});
it("retries without Authorization when GITHUB_TOKEN is rejected", async () => {
@@ -356,10 +357,10 @@ describe("requireGitHubAccountAge", () => {
1,
"https://api.github.com/user/12345",
expect.objectContaining({
headers: {
headers: expect.objectContaining({
"User-Agent": "clawhub",
Authorization: "Bearer ghp_expired",
},
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
@@ -374,7 +375,7 @@ describe("requireGitHubAccountAge", () => {
githubCreatedAt: Date.parse("2020-01-01T00:00:00Z"),
});
expect(warnSpy).toHaveBeenCalledWith(
"[githubAccount] GITHUB_TOKEN was rejected; retrying lookup without auth",
"[githubAccount] GitHub API auth was rejected; retrying lookup without auth",
);
});
@@ -398,9 +399,10 @@ describe("requireGitHubAccountAge", () => {
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/12345",
expect.objectContaining({
headers: { "User-Agent": "clawhub" },
headers: expect.objectContaining({ "User-Agent": "clawhub" }),
}),
);
expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty("Authorization");
});
});
+5 -12
View File
@@ -2,6 +2,7 @@ import { ConvexError } from "convex/values";
import { internal } from "../_generated/api";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { buildGitHubApiHeaders } from "./githubAuth";
import { GITHUB_PROFILE_SYNC_WINDOW_MS } from "./githubProfileSync";
const GITHUB_API = "https://api.github.com";
@@ -22,24 +23,16 @@ function assertGitHubNumericId(providerAccountId: string) {
}
}
function buildGitHubHeaders() {
const headers: Record<string, string> = { "User-Agent": "clawhub" };
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
async function fetchGitHubUserByNumericId(providerAccountId: string) {
assertGitHubNumericId(providerAccountId);
const url = `${GITHUB_API}/user/${providerAccountId}`;
const headers = await buildGitHubApiHeaders({ userAgent: "clawhub" });
const response = await fetch(url, {
headers: buildGitHubHeaders(),
headers,
});
if (response.status !== 401 || !process.env.GITHUB_TOKEN?.trim()) return response;
if (response.status !== 401 || !headers.Authorization) return response;
console.warn("[githubAccount] GITHUB_TOKEN was rejected; retrying lookup without auth");
console.warn("[githubAccount] GitHub API auth was rejected; retrying lookup without auth");
return await fetch(url, {
headers: { "User-Agent": "clawhub" },
});
+28
View File
@@ -76,6 +76,34 @@ describe("fetchGitHubRepositoryIdentity", () => {
);
});
it("does not use GitHub App auth for arbitrary repository lookup", async () => {
vi.stubEnv("GITHUB_APP_ID", "123");
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "456");
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "not-needed-for-this-test");
vi.stubEnv("GITHUB_TOKEN", "ghs_test_token");
const fetchMock = vi.fn(async () =>
Response.json({
id: 123,
full_name: "openclaw/clawhub",
owner: { login: "openclaw", id: 456 },
}),
);
await fetchGitHubRepositoryIdentity("openclaw/clawhub", fetchMock);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/repos/openclaw/clawhub",
expect.objectContaining({
headers: expect.objectContaining({
Accept: "application/vnd.github+json",
Authorization: "Bearer ghs_test_token",
"User-Agent": "clawhub/package-trusted-publisher",
}),
}),
);
});
it("omits Authorization for repository lookup when GITHUB_TOKEN is blank", async () => {
vi.stubEnv("GITHUB_TOKEN", " ");
const fetchMock = vi.fn(async () =>
+13 -11
View File
@@ -1,3 +1,5 @@
import { buildGitHubApiHeaders } from "./githubAuth";
type JwtHeader = {
alg?: unknown;
kid?: unknown;
@@ -217,7 +219,7 @@ export async function fetchGitHubRepositoryIdentity(
throw new Error(`Invalid GitHub repository: ${repository}`);
}
const response = await fetchImpl(`https://api.github.com/repos/${normalizedRepository}`, {
headers: buildGitHubRepositoryLookupHeaders(),
headers: await buildGitHubRepositoryLookupHeaders(fetchImpl),
});
if (!response.ok) {
throw new Error(
@@ -239,16 +241,16 @@ export async function fetchGitHubRepositoryIdentity(
};
}
function buildGitHubRepositoryLookupHeaders() {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "clawhub/package-trusted-publisher",
};
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
async function buildGitHubRepositoryLookupHeaders(fetchImpl: typeof fetch) {
return await buildGitHubApiHeaders({
accept: "application/vnd.github+json",
fetchImpl,
userAgent: "clawhub/package-trusted-publisher",
// This lookup accepts arbitrary public repositories. GitHub App installation
// tokens only see repositories where the App is installed, so prefer PAT or
// anonymous auth here.
useGitHubApp: false,
});
}
export function normalizeGitHubRepository(repository: string) {
+100
View File
@@ -0,0 +1,100 @@
/* @vitest-environment node */
import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildGitHubApiHeaders, createGitHubAppInstallationToken } from "./githubAuth";
function stubGitHubAppEnv() {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
vi.stubEnv("GITHUB_APP_ID", "3536245");
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "987654");
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", privateKey);
}
describe("githubAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("mints a GitHub App installation token from app credentials", async () => {
stubGitHubAppEnv();
const fetchMock = vi.fn(async () =>
Response.json({
token: "ghs_app_token",
expires_at: "2026-02-02T13:00:00Z",
}),
);
await expect(
createGitHubAppInstallationToken({ fetchImpl: fetchMock, userAgent: "clawhub/test" }),
).resolves.toEqual({
token: "ghs_app_token",
expiresAt: Date.parse("2026-02-02T13:00:00Z"),
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/app/installations/987654/access_tokens",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Accept: "application/vnd.github+json",
Authorization: expect.stringMatching(/^Bearer [^.]+\.[^.]+\.[^.]+$/),
"User-Agent": "clawhub/test",
}),
}),
);
});
it("builds API headers with GitHub App auth before PAT fallback", async () => {
stubGitHubAppEnv();
vi.stubEnv("GITHUB_TOKEN", "ghp_pat_token");
const fetchMock = vi.fn(async () =>
Response.json({
token: "ghs_app_token",
expires_at: "2026-02-02T13:00:00Z",
}),
);
await expect(
buildGitHubApiHeaders({ fetchImpl: fetchMock, userAgent: "clawhub/test" }),
).resolves.toEqual({
Accept: "application/vnd.github+json",
Authorization: "Bearer ghs_app_token",
"User-Agent": "clawhub/test",
});
});
it("falls back to GITHUB_TOKEN when GitHub App credentials are absent", async () => {
vi.stubEnv("GITHUB_TOKEN", "ghp_pat_token");
await expect(buildGitHubApiHeaders({ userAgent: "clawhub/test" })).resolves.toEqual({
Accept: "application/vnd.github+json",
Authorization: "Bearer ghp_pat_token",
"User-Agent": "clawhub/test",
});
});
it("can skip GitHub App auth for arbitrary public resources", async () => {
stubGitHubAppEnv();
vi.stubEnv("GITHUB_TOKEN", "ghp_pat_token");
const fetchMock = vi.fn();
await expect(
buildGitHubApiHeaders({
fetchImpl: fetchMock,
userAgent: "clawhub/test",
useGitHubApp: false,
}),
).resolves.toEqual({
Accept: "application/vnd.github+json",
Authorization: "Bearer ghp_pat_token",
"User-Agent": "clawhub/test",
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
+272
View File
@@ -0,0 +1,272 @@
const GITHUB_API = "https://api.github.com";
const DEFAULT_ACCEPT = "application/vnd.github+json";
const DEFAULT_USER_AGENT = "clawhub/github-api";
const APP_TOKEN_CACHE_BUFFER_MS = 60 * 1000;
type FetchImpl = typeof fetch;
type GitHubAppConfig = {
appId: string;
installationId: string;
privateKey: string;
};
type InstallationToken = {
token: string;
expiresAt: number;
};
type CachedInstallationToken = InstallationToken & {
cacheKey: string;
};
let cachedInstallationToken: CachedInstallationToken | null = null;
export function isGitHubAppConfigured(env: NodeJS.ProcessEnv = process.env) {
return Boolean(readGitHubAppConfig(env));
}
export async function buildGitHubApiHeaders(options: {
userAgent: string;
accept?: string;
fetchImpl?: FetchImpl;
allowAnonymous?: boolean;
useGitHubApp?: boolean;
}): Promise<Record<string, string>> {
const headers = buildGitHubHeaders({
userAgent: options.userAgent,
accept: options.accept,
});
if (options.useGitHubApp !== false) {
const appToken = await getCachedGitHubAppInstallationToken({
fetchImpl: options.fetchImpl,
userAgent: options.userAgent,
});
if (appToken) {
headers.Authorization = `Bearer ${appToken}`;
return headers;
}
}
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
return headers;
}
if (options.allowAnonymous === false) {
throw new Error("GitHub API authentication is not configured");
}
return headers;
}
export function buildGitHubHeaders(options: {
userAgent: string;
accept?: string;
token?: string;
isAppJwt?: boolean;
}) {
const headers: Record<string, string> = {
Accept: options.accept ?? DEFAULT_ACCEPT,
"User-Agent": options.userAgent,
};
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
return headers;
}
export async function createGitHubAppInstallationToken(
options: {
fetchImpl?: FetchImpl;
userAgent?: string;
env?: NodeJS.ProcessEnv;
now?: number;
} = {},
): Promise<InstallationToken> {
const env = options.env ?? process.env;
const config = readGitHubAppConfig(env);
if (!config) throw new Error("GitHub App credentials missing");
const jwt = await createGitHubAppJwt(config.appId, config.privateKey, options.now ?? Date.now());
const response = await (options.fetchImpl ?? fetch)(
`${GITHUB_API}/app/installations/${config.installationId}/access_tokens`,
{
method: "POST",
headers: buildGitHubHeaders({
userAgent: options.userAgent ?? DEFAULT_USER_AGENT,
token: jwt,
isAppJwt: true,
}),
},
);
if (!response.ok) {
const message = await response.text();
throw new Error(`GitHub App token failed: ${message}`);
}
const payload = (await response.json()) as { token?: string; expires_at?: string };
const token = payload.token?.trim();
if (!token) throw new Error("GitHub App token missing");
const expiresAt = payload.expires_at ? Date.parse(payload.expires_at) : Number.NaN;
if (!Number.isFinite(expiresAt)) throw new Error("GitHub App token expiry missing");
return { token, expiresAt };
}
async function getCachedGitHubAppInstallationToken(options: {
fetchImpl?: FetchImpl;
userAgent: string;
}) {
const config = readGitHubAppConfig(process.env);
if (!config) return null;
const now = Date.now();
const cacheKey = `${config.appId}:${config.installationId}:${hashCacheKey(config.privateKey)}`;
if (
cachedInstallationToken?.cacheKey === cacheKey &&
cachedInstallationToken.expiresAt - APP_TOKEN_CACHE_BUFFER_MS > now
) {
return cachedInstallationToken.token;
}
try {
const next = await createGitHubAppInstallationToken({
fetchImpl: options.fetchImpl,
userAgent: options.userAgent,
now,
});
cachedInstallationToken = { ...next, cacheKey };
return next.token;
} catch (error) {
console.warn(`[githubAuth] GitHub App token unavailable: ${errorMessage(error)}`);
return null;
}
}
function readGitHubAppConfig(env: NodeJS.ProcessEnv): GitHubAppConfig | null {
const appId = env.GITHUB_APP_ID?.trim();
const installationId = env.GITHUB_APP_INSTALLATION_ID?.trim();
const privateKey = env.GITHUB_APP_PRIVATE_KEY?.trim();
if (!appId || !installationId || !privateKey) return null;
return { appId, installationId, privateKey };
}
async function createGitHubAppJwt(appId: string, rawPrivateKey: string, nowMs: number) {
const now = Math.floor(nowMs / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
const signingInput = `${base64UrlString(JSON.stringify(header))}.${base64UrlString(
JSON.stringify(payload),
)}`;
const key = await importPrivateKey(rawPrivateKey);
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
new TextEncoder().encode(signingInput),
);
return `${signingInput}.${base64UrlBytes(new Uint8Array(signature))}`;
}
async function importPrivateKey(rawPrivateKey: string) {
const { label, der } = parsePem(rawPrivateKey);
const pkcs8 = label === "RSA PRIVATE KEY" ? wrapPkcs1PrivateKeyAsPkcs8(der) : der;
return await crypto.subtle.importKey(
"pkcs8",
pkcs8,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
}
function parsePem(raw: string) {
const normalized = raw.replace(/\\n/g, "\n").trim();
const match = /^-----BEGIN ([A-Z0-9 ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----$/m.exec(
normalized,
);
if (!match) throw new Error("Invalid GitHub App private key");
const label = match[1];
if (label !== "PRIVATE KEY" && label !== "RSA PRIVATE KEY") {
throw new Error(`Unsupported GitHub App private key type: ${label}`);
}
return { label, der: base64ToBytes(match[2]) };
}
function wrapPkcs1PrivateKeyAsPkcs8(pkcs1: Uint8Array) {
const version = derInteger(0);
const rsaEncryptionAlgorithm = derSequence(
new Uint8Array([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]),
new Uint8Array([0x05, 0x00]),
);
return derSequence(version, rsaEncryptionAlgorithm, derOctetString(pkcs1));
}
function derSequence(...parts: Uint8Array[]) {
return derTagged(0x30, concatBytes(parts));
}
function derInteger(value: number) {
return derTagged(0x02, new Uint8Array([value]));
}
function derOctetString(value: Uint8Array) {
return derTagged(0x04, value);
}
function derTagged(tag: number, value: Uint8Array) {
return concatBytes([new Uint8Array([tag]), derLength(value.length), value]);
}
function derLength(length: number) {
if (length < 0x80) return new Uint8Array([length]);
const bytes: number[] = [];
let remaining = length;
while (remaining > 0) {
bytes.unshift(remaining & 0xff);
remaining >>= 8;
}
return new Uint8Array([0x80 | bytes.length, ...bytes]);
}
function concatBytes(parts: Uint8Array[]) {
const total = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function base64UrlString(value: string) {
return base64UrlBytes(new TextEncoder().encode(value));
}
function base64UrlBytes(value: Uint8Array) {
let binary = "";
for (const byte of value) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function base64ToBytes(value: string) {
const binary = atob(value.replace(/\s/g, ""));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function hashCacheKey(value: string) {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
return String(hash);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+3 -54
View File
@@ -1,8 +1,8 @@
"use node";
import { createPrivateKey, createSign } from "node:crypto";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { buildGitHubHeaders, createGitHubAppInstallationToken } from "./githubAuth";
const GITHUB_API = "https://api.github.com";
const DEFAULT_REPO = "clawdbot/skills";
@@ -93,7 +93,7 @@ export async function getGitHubBackupContext(): Promise<GitHubBackupContext> {
const repo = process.env.GITHUB_SKILLS_REPO ?? DEFAULT_REPO;
const root = process.env.GITHUB_SKILLS_ROOT ?? DEFAULT_ROOT;
const [repoOwner, repoName] = parseRepo(repo);
const token = await createInstallationToken();
const { token } = await createGitHubAppInstallationToken({ userAgent: USER_AGENT });
const repoInfo = await githubGet<RepoInfo>(token, `/repos/${repoOwner}/${repoName}`);
const branch = repoInfo.default_branch ?? "main";
@@ -439,48 +439,6 @@ async function fetchStorageBase64(ctx: ActionCtx, storageId: Id<"_storage">) {
return buffer.toString("base64");
}
async function createInstallationToken() {
const appId = process.env.GITHUB_APP_ID;
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !installationId) {
throw new Error("GitHub App credentials missing");
}
const jwt = createAppJwt(appId);
const response = await fetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: buildHeaders(jwt, true),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`GitHub App token failed: ${message}`);
}
const payload = (await response.json()) as { token?: string };
if (!payload.token) throw new Error("GitHub App token missing");
return payload.token;
}
function createAppJwt(appId: string) {
const privateKey = loadPrivateKey();
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
const encodedHeader = base64Url(JSON.stringify(header));
const encodedPayload = base64Url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const sign = createSign("RSA-SHA256");
sign.update(signingInput);
sign.end();
const signature = sign.sign(privateKey);
return `${signingInput}.${base64Url(signature)}`;
}
function loadPrivateKey() {
const raw = process.env.GITHUB_APP_PRIVATE_KEY;
if (!raw) throw new Error("GITHUB_APP_PRIVATE_KEY is not configured");
const normalized = raw.replace(/\\n/g, "\n");
return createPrivateKey(normalized);
}
async function createBlob(token: string, repoOwner: string, repoName: string, content: string) {
const result = await githubPost<{ sha: string }>(
token,
@@ -531,11 +489,7 @@ async function githubPatch(token: string, path: string, body: unknown) {
}
function buildHeaders(token: string, isAppJwt = false) {
return {
Authorization: `${isAppJwt ? "Bearer" : "token"} ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": USER_AGENT,
};
return buildGitHubHeaders({ token, isAppJwt, userAgent: USER_AGENT });
}
function parseRepo(repo: string) {
@@ -570,11 +524,6 @@ function encodePath(path: string) {
.join("/");
}
function base64Url(value: string | Uint8Array) {
const buffer = typeof value === "string" ? Buffer.from(value) : Buffer.from(value);
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function toBase64(value: string) {
return Buffer.from(value).toString("base64");
}
+3 -54
View File
@@ -1,8 +1,8 @@
"use node";
import { createPrivateKey, createSign } from "node:crypto";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { buildGitHubHeaders, createGitHubAppInstallationToken } from "./githubAuth";
const GITHUB_API = "https://api.github.com";
const DEFAULT_REPO = "clawdbot/souls";
@@ -86,7 +86,7 @@ export async function getGitHubSoulBackupContext(): Promise<GitHubBackupContext>
const repo = process.env.GITHUB_SOULS_REPO ?? DEFAULT_REPO;
const root = process.env.GITHUB_SOULS_ROOT ?? DEFAULT_ROOT;
const [repoOwner, repoName] = parseRepo(repo);
const token = await createInstallationToken();
const { token } = await createGitHubAppInstallationToken({ userAgent: USER_AGENT });
const repoInfo = await githubGet<RepoInfo>(token, `/repos/${repoOwner}/${repoName}`);
const branch = repoInfo.default_branch ?? "main";
@@ -297,48 +297,6 @@ async function fetchStorageBase64(ctx: ActionCtx, storageId: Id<"_storage">) {
return buffer.toString("base64");
}
async function createInstallationToken() {
const appId = process.env.GITHUB_APP_ID;
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !installationId) {
throw new Error("GitHub App credentials missing");
}
const jwt = createAppJwt(appId);
const response = await fetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: buildHeaders(jwt, true),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`GitHub App token failed: ${message}`);
}
const payload = (await response.json()) as { token?: string };
if (!payload.token) throw new Error("GitHub App token missing");
return payload.token;
}
function createAppJwt(appId: string) {
const privateKey = loadPrivateKey();
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
const encodedHeader = base64Url(JSON.stringify(header));
const encodedPayload = base64Url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const sign = createSign("RSA-SHA256");
sign.update(signingInput);
sign.end();
const signature = sign.sign(privateKey);
return `${signingInput}.${base64Url(signature)}`;
}
function loadPrivateKey() {
const raw = process.env.GITHUB_APP_PRIVATE_KEY;
if (!raw) throw new Error("GITHUB_APP_PRIVATE_KEY is not configured");
const normalized = raw.replace(/\\n/g, "\n");
return createPrivateKey(normalized);
}
async function createBlob(token: string, repoOwner: string, repoName: string, content: string) {
const result = await githubPost<{ sha: string }>(
token,
@@ -389,11 +347,7 @@ async function githubPatch(token: string, path: string, body: unknown) {
}
function buildHeaders(token: string, isAppJwt = false) {
return {
Authorization: `${isAppJwt ? "Bearer" : "token"} ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": USER_AGENT,
};
return buildGitHubHeaders({ token, isAppJwt, userAgent: USER_AGENT });
}
function parseRepo(repo: string) {
@@ -428,11 +382,6 @@ function encodePath(path: string) {
.join("/");
}
function base64Url(value: string | Uint8Array) {
const buffer = typeof value === "string" ? Buffer.from(value) : Buffer.from(value);
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function toBase64(value: string) {
return Buffer.from(value).toString("base64");
}
+7 -1
View File
@@ -104,7 +104,13 @@ Ensure Convex env is set (auth + embeddings):
- `OPENAI_API_KEY`
- `SITE_URL` (your web app URL)
- Optional webhook env (see `docs/webhook.md`)
- Optional: `GITHUB_TOKEN` (recommended; raises GitHub API limits used by publish gates)
- Recommended GitHub App env for authenticated GitHub API reads used by publish
gates and backups:
- `GITHUB_APP_ID`
- `GITHUB_APP_INSTALLATION_ID`
- `GITHUB_APP_PRIVATE_KEY`
- Optional fallback: `GITHUB_TOKEN` (used when GitHub App auth is unavailable,
and for arbitrary public repository lookups such as trusted-publisher setup)
## 2) Deploy web app (Vercel)
+9 -4
View File
@@ -296,10 +296,15 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
- Gate applies to web uploads, CLI publish, GitHub import, and comments.
- If GitHub responds `403` or `429`, publish fails with:
- `GitHub API rate limit exceeded — please try again in a few minutes`
- To reduce rate-limit failures, set `GITHUB_TOKEN` in Convex env for authenticated
GitHub API requests. The same token is used for trusted-publisher repository
identity lookups.
- If a configured `GITHUB_TOKEN` is rejected with `401`, retry the account-age
- To reduce rate-limit failures, configure the ClawHub GitHub App in Convex env:
`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PRIVATE_KEY`.
Account-age/profile lookups prefer short-lived GitHub App installation
tokens, then fall back to `GITHUB_TOKEN`, then to unauthenticated public
requests where safe. Trusted-publisher repository identity lookups avoid
GitHub App installation tokens because users may configure repositories
outside the App installation; they use `GITHUB_TOKEN` or unauthenticated
public requests instead.
- If configured GitHub API auth is rejected with `401`, retry the account-age
lookup without auth before failing. Never fall back to mutable GitHub usernames
for this gate; use the operator backfill to cache missing ages for existing users.