Skip to content

Commit dbef719

Browse files
V48 Gate 3 (specification-implementation): fix GitHub installation-token regeneration so Refresh actually heals an Invalid connection (V48-Gate3-F33)
Asked live: can "Refresh" re-validate a GitHub connection instead of forcing a full Disconnect + GitHub App uninstall/reinstall? It's already designed to — VCSConnections.getAuthFromConnection silently mints a fresh installation token via GitHubAppAuth when the stored one is stale, no reinstall needed. Confirmed GITHUB_APP_ID/GITHUB_PRIVATE_KEY are correctly configured across all three Vercel targets, so this wasn't an env gap. Found the actual bug by inspection: the expiry check that decides whether to regenerate reads installation_token_expires_at, but the regeneration's own updateTokens() call only ever persisted the fresh expiry to a different field, token_expires_at. Every regeneration "succeeded" for that one request, but the field the next check reads never moved off its original, already-past value — forcing a full re-regeneration attempt on every single subsequent call, forever. Also fixed GET /api/vcs/[provider]/connection to re-fetch the connection after validateStoredConnection runs, so a same-request regeneration shows up immediately instead of one Refresh click behind. If the GitHub App installation was actually revoked on GitHub's side (not just an expired token), regeneration will still fail and reinstall really is required then — that's the one case Refresh can't fix by design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 56732d7 commit dbef719

4 files changed

Lines changed: 130 additions & 3 deletions

File tree

BITCODE_V48_QA.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,13 @@ Track 3-4 scripts (BTD ledger, settlement, pack journaling) get added when those
526526
- Cause: `DepositSourceSelection.tsx` (the deposit-native replacement for the legacy `TerminalRepositoryContextPanel`/`TerminalSupplySelectionPanel`) fetches the repository list gated only on `connectionStatus.connected`, but gates the Branch and Commit fetch effects on BOTH `connectionStatus.connected` AND `connectionStatus.valid` (same asymmetry as the legacy panel). `valid` comes from `validateStoredConnection`, which re-checks the live GitHub session/token — a GitHub App installation token expires ~hourly and is regenerated lazily, so `connected:true, valid:false` is a normal, recurring state, not an edge case. In that state a repository is still selectable (stored inventory doesn't need `valid`), but Branch/Commit silently stay at `[]` forever: `disabled={... || branches.length === 0}` (and the same for commits) keeps the selects inert with no loading spinner and no error text — the legacy panel this replaced had dedicated "reconnect required" messaging + a Reconnect CTA for exactly this state (`connectionStatus?.connected && !connectionStatus.valid`, `TerminalRepositoryContextPanel.tsx`), but that messaging was dropped when `DepositSourceSelection` replaced it — the refactor's docblock even says it "carries no terminal-UI dependency," which is what left this state unhandled.
527527
- Repair: added a `connectionNeedsReconnect` derived flag to `DepositSourceSelection.tsx`; when true, an amber banner (mirroring the legacy panel's messaging) explains that Branch/Commit stay empty until the live session reconnects, with a "Reconnect GitHub" button (`TerminalOpenAuxillariesButton`, opens Auxillaries → Externals — reused directly since its own logic has no terminal-workspace coupling, only its file location is under `app/terminal/`). The Branch/Commit helper hints below each select also swap to "Reconnect required to load branches/commits" instead of the normal "default is selected when available" copy in this state. `TerminalOpenAuxillariesButton.tsx` was missing an explicit `React` import (never directly rendered by a test before — ts-jest's classic JSX transform needs it in scope); added it. Verified: `tsc --noEmit` 0; new `depositSourceSelection.test.tsx` coverage (valid connection → no banner; invalid → banner + Reconnect button + both hint texts) 5/5 green; full uapi jest suite 639/640 green (1 pre-existing skip).
528528

529+
### V48-Gate3-F33 — GitHub "Refresh" (re-validate) never healed an Invalid connection: installation-token regeneration checked one expiry field but persisted to another (FIXED)
530+
531+
- Severity: high (asked live, 2026-07-08, re: the Externals GitHub card showing "Invalid" with a past Token-expires date: "can Refresh work to re-connect/re-validate instead of disconnecting" — disconnect forces a full GitHub App uninstall+reinstall).
532+
- Confirmed by design, NOT by env config: Refresh (`VCSConnectionCard.tsx`'s `handleRefresh``checkConnection``GET /api/vcs/[provider]/connection`) already re-validates AND is supposed to silently regenerate an expired GitHub App installation token with no reinstall needed — `VCSConnections.getAuthFromConnection` (`packages/vcs/src/connections.ts`) mints a fresh installation token via `GitHubAppAuth.generateInstallationToken` whenever the stored one is missing/expired. Confirmed `GITHUB_APP_ID`/`GITHUB_PRIVATE_KEY`/`GITHUB_APP_CLIENT_ID`/`GITHUB_APP_CLIENT_SECRET` are present on all three Vercel targets (Preview, Production, staginglocal-testnet) via `vercel env ls` — so this isn't an env-configuration gap.
533+
- Cause (a real bug, found by inspection): the expiry check that decides whether to regenerate reads `connectionData.installation_token_expires_at` (line ~193) — the field `_callback-handler.ts` writes at initial install. But the regeneration's own persistence call, `this.updateTokens(...)`, only ever wrote `token_expires_at` — a DIFFERENT field, never read by that check. So every regeneration attempt "succeeded" (fresh token minted, `validateToken` passes for that one request) yet the field the NEXT check reads never moved off its original, already-past value — forcing a full re-regeneration on every single subsequent call (every Refresh click, every repo/branch/commit fetch that reaches this path) forever. Separately, `GET /api/vcs/[provider]/connection` built the returned status from the `connection` row fetched BEFORE `validateStoredConnection` ran, so even a successful in-request regeneration wouldn't show its fresh `expiresAt`/metadata until a subsequent call.
534+
- Repair: `VCSConnections.updateTokens` now writes `installation_token_expires_at` alongside `token_expires_at` (its only caller is this one GitHub-App regeneration path, so this is safe with no other call sites to consider). `GET /api/vcs/[provider]/connection` now re-fetches the connection after a successful `validateStoredConnection` before building the response, so a same-request regeneration is reflected immediately instead of one Refresh click behind. Note: if the GitHub App installation itself was actually revoked/uninstalled on GitHub's side (not just an expired token), regeneration will keep failing and Disconnect+reinstall genuinely is required then — check https://github.com/settings/installations (or the org's) to tell the two apart. Verified: `tsc --noEmit` 0 (both packages); new `packages/vcs/src/__tests__/connections.test.ts` (2/2, pins both fields moving together and that a fresh token skips re-regeneration); full uapi jest suite 639/640 green (1 pre-existing skip).
535+
529536
## Track 1 — Identity / Authentication / Auxillaries — COMPLETE 2026-06-12 (email deferred by decision; F2/F9 and legacy eradication queued for gates)
530537

531538
- [x] Sign up / sign in via Connect Wallet (nav CTA → SignUpWindow → wallet signature on testnet4 → `custom:bitcode-bitcoin` session → `/tps/supabase/callback`) — verified 2026-06-12 after F5 fix; lands on `/packs`. Re-verified from fully nuked state (purged user + cleared site data): created 19:29:21 → session 19:29:25 → binding auto-written 19:29:29 by the bridge on `/packs` mount with no Auxillaries visit; UI consistent across nav, Wallet, and Profile panes.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* V48-Gate3-F33: getAuthFromConnection's GitHub App installation-token
3+
* regeneration reads `installation_token_expires_at` to decide whether the
4+
* stored token is stale, but its own updateTokens() call used to persist the
5+
* fresh expiry ONLY to `token_expires_at` — a different field, never read by
6+
* that same expiry check. So every regeneration "succeeded" yet the field
7+
* gate on kept reporting stale, forcing a full re-regeneration on literally
8+
* every subsequent call (Refresh, repo/branch/commit fetches, etc.) forever.
9+
* Pin that both fields move together.
10+
*/
11+
12+
const mockGetById = jest.fn();
13+
const mockUpdate = jest.fn();
14+
15+
jest.mock('@bitcode/orm', () => ({
16+
UserConnectionsModel: jest.fn().mockImplementation(() => ({
17+
getById: mockGetById,
18+
update: mockUpdate,
19+
getByUserAndProvider: jest.fn(),
20+
listByUserId: jest.fn(),
21+
getByProviderUserId: jest.fn(),
22+
getAuthFromConnectionByInstallationId: jest.fn(),
23+
})),
24+
}));
25+
26+
const mockGenerateInstallationToken = jest.fn();
27+
28+
jest.mock('@bitcode/github', () => ({
29+
GitHubAppAuth: jest.fn().mockImplementation(() => ({
30+
generateInstallationToken: mockGenerateInstallationToken,
31+
})),
32+
}));
33+
34+
import { VCSConnections } from '../connections';
35+
36+
const CONNECTION_ID = '11111111-1111-1111-1111-111111111111';
37+
const FAR_FUTURE_ISO = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 5).toISOString();
38+
const FAR_PAST_ISO = new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 5).toISOString();
39+
40+
describe('VCSConnections.getAuthFromConnection — GitHub installation token regeneration', () => {
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
process.env.GITHUB_APP_ID = '12345';
44+
process.env.GITHUB_PRIVATE_KEY = 'test-private-key';
45+
});
46+
47+
afterEach(() => {
48+
delete process.env.GITHUB_APP_ID;
49+
delete process.env.GITHUB_PRIVATE_KEY;
50+
});
51+
52+
it('regenerates an expired installation token and persists both expiry fields in lockstep', async () => {
53+
mockGetById.mockResolvedValue({
54+
id: CONNECTION_ID,
55+
provider: 'github',
56+
connection_data: {
57+
connectionId: '99999',
58+
access_token: 'stale-token',
59+
installation_token_expires_at: FAR_PAST_ISO,
60+
},
61+
});
62+
mockGenerateInstallationToken.mockResolvedValue({
63+
token: 'fresh-token',
64+
expiresAt: new Date(FAR_FUTURE_ISO),
65+
});
66+
67+
const connections = new VCSConnections({} as never);
68+
const auth = await connections.getAuthFromConnection(CONNECTION_ID);
69+
70+
expect(auth?.accessToken).toBe('fresh-token');
71+
expect(mockUpdate).toHaveBeenCalledWith(
72+
CONNECTION_ID,
73+
expect.objectContaining({
74+
connection_data: expect.objectContaining({
75+
access_token: 'fresh-token',
76+
token_expires_at: FAR_FUTURE_ISO,
77+
installation_token_expires_at: FAR_FUTURE_ISO,
78+
}),
79+
}),
80+
);
81+
});
82+
83+
it('does not re-attempt regeneration once installation_token_expires_at reflects the fresh token', async () => {
84+
mockGetById.mockResolvedValue({
85+
id: CONNECTION_ID,
86+
provider: 'github',
87+
connection_data: {
88+
connectionId: '99999',
89+
access_token: 'already-fresh-token',
90+
installation_token_expires_at: FAR_FUTURE_ISO,
91+
},
92+
});
93+
94+
const connections = new VCSConnections({} as never);
95+
const auth = await connections.getAuthFromConnection(CONNECTION_ID);
96+
97+
expect(auth?.accessToken).toBe('already-fresh-token');
98+
expect(mockGenerateInstallationToken).not.toHaveBeenCalled();
99+
expect(mockUpdate).not.toHaveBeenCalled();
100+
});
101+
});

packages/vcs/src/connections.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,16 @@ export class VCSConnections {
319319
...connectionData,
320320
access_token: tokens.accessToken,
321321
refresh_token: tokens.refreshToken || readString(connectionData.refresh_token),
322-
token_expires_at: tokens.expiresAt?.toISOString()
322+
token_expires_at: tokens.expiresAt?.toISOString(),
323+
// This is the ONLY caller of updateTokens, and it's the GitHub App
324+
// installation-token regeneration path in getAuthFromConnection below
325+
// — which decides "is the token expired" by reading
326+
// installation_token_expires_at (V48-Gate3-F33), not token_expires_at.
327+
// Writing only token_expires_at here left that check permanently
328+
// stale: every regeneration "succeeded" but the field the NEXT check
329+
// reads never moved, so it re-triggered a full regeneration on every
330+
// single call forever. Keep both fields in lockstep.
331+
installation_token_expires_at: tokens.expiresAt?.toISOString()
323332
} as Database['public']['Tables']['user_connections']['Update']['connection_data'];
324333

325334
await this.connections.update(connectionId, {

uapi/app/api/vcs/[provider]/connection/route.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,23 @@ export const GET = createRouteWrapper(async (request: Request, context: Provider
6464

6565
const instanceUrl = readInstanceUrl(request);
6666
const valid = await validateStoredConnection(manager, provider, connection, instanceUrl).catch(() => false);
67-
const connectionStatus = buildStoredConnectionStatus(provider, connection, valid);
67+
// V48-Gate3-F33: validateStoredConnection may have just regenerated and
68+
// persisted a fresh GitHub App installation token (getAuthFromConnection).
69+
// `connection` above was fetched BEFORE that write, so building the status
70+
// from it would show the pre-regeneration token_expires_at/metadata for
71+
// this whole response — one Refresh click behind reality. Re-fetch so a
72+
// successful silent regeneration is visible immediately, not on the next
73+
// click.
74+
const refreshedConnection = valid
75+
? (await manager.getConnection(user.id, provider).catch(() => null)) || connection
76+
: connection;
77+
const connectionStatus = buildStoredConnectionStatus(provider, refreshedConnection, valid);
6878

6979
return NextResponse.json({
7080
...connectionStatus,
7181
providerReadiness: buildAuxillariesConnectionReadiness({
7282
provider,
73-
connection: connection.connectionData,
83+
connection: refreshedConnection.connectionData,
7484
connectionStatus,
7585
repositories: valid ? [{}] : [],
7686
}),

0 commit comments

Comments
 (0)