Skip to content

Commit bfcf1c4

Browse files
vn7n24fzkqclaude
andauthored
feat(api): GitHub App installation tokens join the token pool (#315)
* feat(api): GitHub App installation tokens join the token pool Adds App installation tokens as first-class slots ahead of the env PATs, so the hosted service can run on App quota and the personal PAT can retire from the pool (#308 follow-up). - api/utils/github-app-token.ts mints installation tokens from GH_APP_ID + GH_APP_PRIVATE_KEY (PEM verbatim, \n-escaped, or base64) with a hand-rolled RS256 JWT via Node crypto — no new dependency. Tokens cache in module memory per instance, refresh 5 minutes before expiry, and concurrent callers share one in-flight mint (~2 REST calls per installation per instance per hour). Never logged, never in Redis. - One slot PER INSTALLATION: GH_APP_INSTALLATION_IDS (comma-separated) pins the installations; each has its own independent 5,000-point hourly GraphQL quota, so installing the App on both backing accounts yields two pools from one credential. Unset, the first discovered installation is used. - The rotation acquires tokens inside the retry loop: a failed mint is flagged isTokenAcquisition and rotates to the next slot exactly like a rate-limited PAT — a broken App degrades to PAT service, not to error cards. Slot names (GITHUB_APP_n / GITHUB_TOKEN_n) keep logs readable without exposing accounts. Rollout: create the App (no extra permissions; default read-only metadata is enough — cards read public data only), install it on the backing accounts, set the three env vars, deploy, then delete the personal GITHUB_TOKEN env and rename GITHUB_TOKEN_1 to GITHUB_TOKEN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: bound App token mint with a timeout; per-test axios mock lifecycle Review feedback: appApi now carries an explicit 10s axios timeout so a hanging GitHub API call degrades to the PAT slots within the request instead of riding the invocation to the platform kill. The handle-card App-slot tests recreate the MockAdapter per test — restore() detaches the adapter, so a suite-level instance left later tests unmocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c2d6565 commit bfcf1c4

6 files changed

Lines changed: 508 additions & 12 deletions

File tree

api/utils/github-app-token.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as crypto from 'crypto';
2+
import axios from 'axios';
3+
4+
// GitHub App installation tokens: minted on demand from the App's private key,
5+
// valid for one hour, with their own 5,000-point GraphQL quota per
6+
// INSTALLATION — install the same App on several accounts and each
7+
// installation is an independent quota pool, all from one credential.
8+
//
9+
// Configuration (Vercel env):
10+
// GH_APP_ID — the App's numeric id
11+
// GH_APP_PRIVATE_KEY — PEM, either verbatim (multiline / \n-escaped) or base64
12+
// GH_APP_INSTALLATION_IDS — optional, comma-separated; one token slot per id.
13+
// Omitted: the first discovered installation is used.
14+
//
15+
// Minted tokens are cached in module memory per lambda instance and refreshed
16+
// shortly before expiry — roughly two REST calls per installation per instance
17+
// per hour. They are never logged and never written to Redis.
18+
19+
const REFRESH_MARGIN_MS = 5 * 60 * 1000; // re-mint when under 5 minutes left
20+
// A hanging mint would otherwise ride the whole function invocation while the
21+
// rotation can't move on — bound it so a slow GitHub API degrades to the PAT
22+
// slots within a request, not at the platform timeout.
23+
const MINT_TIMEOUT_MS = 10 * 1000;
24+
25+
const cachedTokens = new Map<number, {token: string; expiresAtMs: number}>();
26+
const inflightMints = new Map<number, Promise<string>>();
27+
let discoveredInstallationId: number | null = null;
28+
29+
export function isGitHubAppConfigured(): boolean {
30+
return Boolean(process.env.GH_APP_ID && process.env.GH_APP_PRIVATE_KEY);
31+
}
32+
33+
function configuredInstallationIds(): number[] {
34+
return (process.env.GH_APP_INSTALLATION_IDS ?? '')
35+
.split(',')
36+
.map(s => Number(s.trim()))
37+
.filter(n => Number.isInteger(n) && n > 0);
38+
}
39+
40+
// Slot count must be known synchronously for the rotation pool, so it comes
41+
// from env only: one slot per configured installation id, or a single slot
42+
// (first discovered installation) when none are pinned.
43+
export function getGitHubAppSlotCount(): number {
44+
if (!isGitHubAppConfigured()) {
45+
return 0;
46+
}
47+
return Math.max(1, configuredInstallationIds().length);
48+
}
49+
50+
// Vercel env vars arrive in several shapes: verbatim PEM, PEM with literal \n
51+
// escapes, or base64 of the whole file. Normalize all three.
52+
function resolvePrivateKey(): string {
53+
let key = process.env.GH_APP_PRIVATE_KEY ?? '';
54+
if (!key.includes('-----BEGIN')) {
55+
key = Buffer.from(key, 'base64').toString('utf8');
56+
}
57+
return key.replace(/\\n/g, '\n');
58+
}
59+
60+
// A short-lived RS256 JWT identifying the App itself (not an installation).
61+
// Node's crypto signs it directly — no jsonwebtoken dependency.
62+
function buildAppJwt(): string {
63+
const now = Math.floor(Date.now() / 1000);
64+
const encode = (obj: object): string => Buffer.from(JSON.stringify(obj)).toString('base64url');
65+
// iat backdated 60s against clock drift; GitHub caps exp at 10 minutes.
66+
const unsigned = `${encode({alg: 'RS256', typ: 'JWT'})}.${encode({
67+
iat: now - 60,
68+
exp: now + 540,
69+
iss: process.env.GH_APP_ID
70+
})}`;
71+
const signature = crypto.createSign('RSA-SHA256').update(unsigned).sign(resolvePrivateKey(), 'base64url');
72+
return `${unsigned}.${signature}`;
73+
}
74+
75+
async function appApi(method: 'get' | 'post', path: string, jwt: string): Promise<any> {
76+
return axios({
77+
url: `https://api.github.com${path}`,
78+
method,
79+
headers: {
80+
'User-Agent': 'github-profile-summary-cards',
81+
Authorization: `Bearer ${jwt}`,
82+
Accept: 'application/vnd.github+json'
83+
},
84+
timeout: MINT_TIMEOUT_MS
85+
});
86+
}
87+
88+
async function resolveInstallationId(slot: number, jwt: string): Promise<number> {
89+
const pinned = configuredInstallationIds();
90+
if (pinned.length > 0) {
91+
return pinned[slot];
92+
}
93+
if (discoveredInstallationId !== null) {
94+
return discoveredInstallationId;
95+
}
96+
const res = await appApi('get', '/app/installations', jwt);
97+
const id = res.data?.[0]?.id;
98+
if (!id) {
99+
throw new Error('GitHub App has no installations');
100+
}
101+
discoveredInstallationId = id;
102+
return id;
103+
}
104+
105+
async function mintInstallationToken(slot: number): Promise<string> {
106+
const jwt = buildAppJwt();
107+
const installationId = await resolveInstallationId(slot, jwt);
108+
const res = await appApi('post', `/app/installations/${installationId}/access_tokens`, jwt);
109+
const token = res.data?.token;
110+
if (!token) {
111+
throw new Error('GitHub App token mint returned no token');
112+
}
113+
cachedTokens.set(slot, {
114+
token,
115+
expiresAtMs: res.data.expires_at ? Date.parse(res.data.expires_at) : Date.now() + 55 * 60 * 1000
116+
});
117+
return token;
118+
}
119+
120+
/**
121+
* Returns a valid installation token for an App slot, minting or refreshing as
122+
* needed. Concurrent callers of the same slot share one in-flight mint. Throws
123+
* when the App is not configured or GitHub rejects the mint — callers treat
124+
* that as "this slot can't serve the request" and rotate on.
125+
*
126+
* @param {number} slot - App slot index, 0 <= slot < getGitHubAppSlotCount().
127+
* @return {Promise<string>} The installation access token.
128+
*/
129+
export async function getGitHubAppToken(slot = 0): Promise<string> {
130+
if (!isGitHubAppConfigured()) {
131+
throw new Error('GitHub App is not configured');
132+
}
133+
if (slot < 0 || slot >= getGitHubAppSlotCount()) {
134+
throw new Error(`GitHub App slot out of range: ${slot}`);
135+
}
136+
const cached = cachedTokens.get(slot);
137+
if (cached && cached.expiresAtMs - Date.now() > REFRESH_MARGIN_MS) {
138+
return cached.token;
139+
}
140+
let mint = inflightMints.get(slot);
141+
if (!mint) {
142+
mint = mintInstallationToken(slot).finally(() => {
143+
inflightMints.delete(slot);
144+
});
145+
inflightMints.set(slot, mint);
146+
}
147+
return mint;
148+
}
149+
150+
/** Test hook: clears module-level caches between test cases. */
151+
export function __resetGitHubAppTokenCacheForTests(): void {
152+
cachedTokens.clear();
153+
inflightMints.clear();
154+
discoveredInstallationId = null;
155+
}

api/utils/github-token-updater.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {getGitHubAppSlotCount, getGitHubAppToken} from './github-app-token';
2+
13
// Returns the env var name a given token index resolves from: GITHUB_TOKEN_<n>
24
// when set, with GITHUB_TOKEN as the index-0 fallback. Safe to log — it names
35
// the slot, never the token value or the account behind it.
@@ -38,3 +40,42 @@ export const getGitHubTokenCount = function (): number {
3840
}
3941
return count;
4042
};
43+
44+
// ---- unified slot view: GitHub App installations + env PATs ----
45+
// App installations come first (each installation token has its own hourly
46+
// quota, independent of every PAT account and of each other); the env PATs
47+
// follow, shifted by the App slot count. Without an App the slots are exactly
48+
// the env PATs.
49+
export const getGitHubTokenSlots = function (): number {
50+
return getGitHubAppSlotCount() + getGitHubTokenCount();
51+
};
52+
53+
// Loggable slot name — 'GITHUB_APP[_n]' for App slots, env var names otherwise.
54+
export const getGitHubTokenNameAt = function (index: number): string {
55+
const appSlots = getGitHubAppSlotCount();
56+
if (index < appSlots) {
57+
return appSlots === 1 ? 'GITHUB_APP' : `GITHUB_APP_${index}`;
58+
}
59+
return getGitHubTokenName(index - appSlots);
60+
};
61+
62+
// Resolves the token at a slot. App slots mint (or serve the cached)
63+
// installation token; mint failures are flagged `isTokenAcquisition` so the
64+
// rotation treats a broken App like a rate-limited PAT — try the next slot
65+
// instead of failing the card.
66+
export const getGitHubTokenAt = async function (index: number): Promise<string> {
67+
const appSlots = getGitHubAppSlotCount();
68+
if (index < appSlots) {
69+
try {
70+
const token = await getGitHubAppToken(index);
71+
console.log(`Using token source: ${getGitHubTokenNameAt(index)}`);
72+
return token;
73+
} catch (err: any) {
74+
if (err && typeof err === 'object') {
75+
err.isTokenAcquisition = true;
76+
}
77+
throw err;
78+
}
79+
}
80+
return getGitHubToken(index - appSlots);
81+
};

api/utils/handle-card.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {getGitHubToken, getGitHubTokenCount, getGitHubTokenName} from './github-token-updater';
1+
import {getGitHubTokenAt, getGitHubTokenSlots, getGitHubTokenNameAt} from './github-token-updater';
22
import {getErrorMsgCard} from './error-card';
33
import {reportUnexpectedError, shipErrorRecord} from './error-reporter';
44
import {waitUntil} from '@vercel/functions';
@@ -110,17 +110,20 @@ export async function handleCard(
110110

111111
try {
112112
await runWithRequestClock(async () => {
113-
// Zero configured tokens keeps the old behavior: the first
114-
// getGitHubToken(0) call throws the canonical "No more GITHUB_TOKEN"
115-
// error before any render is attempted.
116-
const tokenCount = Math.max(1, getGitHubTokenCount());
113+
// Slots = GitHub App installation token (when configured) + env
114+
// PATs. Zero configured slots keeps the old behavior: the first
115+
// getGitHubTokenAt(0) call throws the canonical "No more
116+
// GITHUB_TOKEN" error before any render is attempted.
117+
const tokenCount = Math.max(1, getGitHubTokenSlots());
117118
let attempts = 0;
118119
let tokenIndex = tokenPoolStartIndex(username, tokenCount);
119-
let token = getGitHubToken(tokenIndex);
120-
// Rotate through the configured tokens (wrapping around the pool)
121-
// until one succeeds or every token has been tried.
120+
// Rotate through the configured slots (wrapping around the pool)
121+
// until one succeeds or every slot has been tried. Acquisition
122+
// happens inside the try: a failed App-token mint rotates to the
123+
// next slot exactly like a rate-limited PAT.
122124
while (true) {
123125
try {
126+
const token = await getGitHubTokenAt(tokenIndex);
124127
// Collect the data-cache outcome of this render so GA gets a
125128
// cache_status dimension (fresh / miss / stale / mixed).
126129
const {result: cardSVG, cacheStatus} = await runWithCacheStats(() =>
@@ -151,17 +154,16 @@ export async function handleCard(
151154
return;
152155
} catch (err: any) {
153156
console.log(
154-
`${getGitHubTokenName(tokenIndex)} failed: ${redactBackingAccount(String(err?.message ?? 'unknown'))}`
157+
`${getGitHubTokenNameAt(tokenIndex)} failed: ${redactBackingAccount(String(err?.message ?? 'unknown'))}`
155158
);
156-
if (isRotatableError(err)) {
159+
if (isRotatableError(err) || err?.isTokenAcquisition === true) {
157160
attempts += 1;
158161
if (attempts >= tokenCount) {
159162
// Keep the "No more GITHUB_TOKEN" phrasing — classifyError
160163
// maps it to the rate_limited card message.
161164
throw new Error('No more GITHUB_TOKEN can be used (all configured tokens failed)');
162165
}
163166
tokenIndex = (tokenIndex + 1) % tokenCount;
164-
token = getGitHubToken(tokenIndex);
165167
} else {
166168
throw err;
167169
}

0 commit comments

Comments
 (0)