Skip to content

Commit 2746f25

Browse files
authored
Merge pull request #349 from mean-weasel/codex/installation-deletion-safeguard
Add installation deletion safeguards
2 parents 9750faa + 3f9c5d1 commit 2746f25

11 files changed

Lines changed: 1233 additions & 13 deletions

src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { Hono } from 'hono';
22
import { logger } from 'hono/logger';
33
import type { Env } from './types';
44
import api from './routes/api';
5+
import githubWebhook from './routes/github-webhook';
56
import { createBoardDogfoodToken } from './lib/boardDogfood';
7+
import { sweepInstallationRecords } from './lib/installation-retention';
68

79
const app = new Hono<{ Bindings: Env }>();
810

@@ -45,6 +47,7 @@ app.use('*', logger());
4547

4648
// Mount API routes
4749
app.route('/api', api);
50+
app.route('/api', githubWebhook);
4851

4952
export function isWeakAuthTokenSecret(secret?: string): boolean {
5053
return typeof secret === 'string' && secret.length > 0 && secret.length < 32;
@@ -141,4 +144,15 @@ function serveAsset(c: { env: Env; req: { raw: Request } }, pathname: string): P
141144
return c.env.ASSETS.fetch(new Request(url, c.req.raw));
142145
}
143146

144-
export default app;
147+
export async function scheduled(
148+
_controller: ScheduledController,
149+
env: Env,
150+
_ctx: ExecutionContext
151+
): Promise<void> {
152+
await sweepInstallationRecords(env);
153+
}
154+
155+
export default {
156+
fetch: app.fetch,
157+
scheduled,
158+
};

src/lib/github.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { generateGitHubAppJWT } from './jwt';
22
import type { Env, FeedbackAttachment, GitHubIssue } from '../types';
33

4-
const GITHUB_API = 'https://api.github.com';
4+
export const GITHUB_API = 'https://api.github.com';
55

66
/**
77
* Thrown when GitHub rejects an issue creation specifically due to invalid
@@ -18,7 +18,7 @@ export class GitHubLabelError extends Error {
1818
}
1919
}
2020

21-
const headers = (token: string) => ({
21+
export const githubHeaders = (token: string) => ({
2222
Authorization: `Bearer ${token}`,
2323
Accept: 'application/vnd.github+json',
2424
'Content-Type': 'application/json',
@@ -38,7 +38,7 @@ async function getInstallationId(env: Env, owner: string, repo: string): Promise
3838
const jwt = await generateGitHubAppJWT(env.GITHUB_APP_ID, env.GITHUB_PRIVATE_KEY);
3939

4040
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/installation`, {
41-
headers: headers(jwt),
41+
headers: githubHeaders(jwt),
4242
});
4343

4444
if (!response.ok) {
@@ -65,7 +65,7 @@ export async function getInstallationToken(
6565

6666
const response = await fetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, {
6767
method: 'POST',
68-
headers: headers(jwt),
68+
headers: githubHeaders(jwt),
6969
});
7070

7171
if (!response.ok) {
@@ -90,7 +90,7 @@ export async function createIssue(
9090
): Promise<GitHubIssue> {
9191
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/issues`, {
9292
method: 'POST',
93-
headers: headers(token),
93+
headers: githubHeaders(token),
9494
body: JSON.stringify({ title, body, labels }),
9595
});
9696

@@ -128,7 +128,7 @@ function isLabelValidationFailure(body: string): boolean {
128128
export async function isRepoPublic(token: string, owner: string, repo: string): Promise<boolean> {
129129
try {
130130
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, {
131-
headers: headers(token),
131+
headers: githubHeaders(token),
132132
});
133133

134134
if (!response.ok) {
@@ -151,7 +151,7 @@ async function ensureScreenshotBranch(token: string, owner: string, repo: string
151151
// Check if branch already exists
152152
const check = await fetch(
153153
`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${SCREENSHOT_BRANCH}`,
154-
{ headers: headers(token) }
154+
{ headers: githubHeaders(token) }
155155
);
156156
if (check.ok) return;
157157
if (check.status !== 404) {
@@ -160,7 +160,7 @@ async function ensureScreenshotBranch(token: string, owner: string, repo: string
160160

161161
// Get default branch SHA
162162
const repoRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, {
163-
headers: headers(token),
163+
headers: githubHeaders(token),
164164
});
165165
if (!repoRes.ok) {
166166
throw new Error(`Failed to get repo info: ${repoRes.status}`);
@@ -169,7 +169,7 @@ async function ensureScreenshotBranch(token: string, owner: string, repo: string
169169

170170
const refRes = await fetch(
171171
`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${repoData.default_branch}`,
172-
{ headers: headers(token) }
172+
{ headers: githubHeaders(token) }
173173
);
174174
if (!refRes.ok) {
175175
throw new Error(`Failed to get default branch ref: ${refRes.status}`);
@@ -179,7 +179,7 @@ async function ensureScreenshotBranch(token: string, owner: string, repo: string
179179
// Create the screenshot branch
180180
const createRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
181181
method: 'POST',
182-
headers: headers(token),
182+
headers: githubHeaders(token),
183183
body: JSON.stringify({
184184
ref: `refs/heads/${SCREENSHOT_BRANCH}`,
185185
sha: refData.object.sha,
@@ -190,7 +190,7 @@ async function ensureScreenshotBranch(token: string, owner: string, repo: string
190190
if (createRes.status === 422 && isExistingReferenceError(error)) {
191191
const recheck = await fetch(
192192
`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${SCREENSHOT_BRANCH}`,
193-
{ headers: headers(token) }
193+
{ headers: githubHeaders(token) }
194194
);
195195
if (recheck.ok) return;
196196
}
@@ -271,7 +271,7 @@ async function uploadBase64Asset(
271271

272272
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/${filename}`, {
273273
method: 'PUT',
274-
headers: headers(token),
274+
headers: githubHeaders(token),
275275
body: JSON.stringify({
276276
message,
277277
content: content,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
export interface InstallationCleanupAudit {
2+
schemaVersion: 1;
3+
completedAt: string;
4+
scannedCount: number;
5+
activeCount: number;
6+
deletedCount: number;
7+
}
8+
9+
interface InstallationScanningCheckpoint {
10+
schemaVersion: 1;
11+
phase: 'scanning';
12+
cursor: string;
13+
startedAt: string;
14+
scannedCount: number;
15+
deletedCount: number;
16+
}
17+
18+
interface InstallationFinalizingCheckpoint {
19+
schemaVersion: 1;
20+
phase: 'finalizing';
21+
audit: InstallationCleanupAudit;
22+
}
23+
24+
export type InstallationStableCheckpoint =
25+
InstallationScanningCheckpoint | InstallationFinalizingCheckpoint;
26+
27+
export interface InstallationDeletingCheckpoint {
28+
schemaVersion: 1;
29+
phase: 'deleting';
30+
installationIds: number[];
31+
next: InstallationStableCheckpoint;
32+
}
33+
34+
export type InstallationCleanupCheckpoint =
35+
InstallationStableCheckpoint | InstallationDeletingCheckpoint;
36+
37+
export function parseInstallationCleanupCheckpoint(
38+
value: string | null
39+
): InstallationCleanupCheckpoint | null {
40+
if (!value) return null;
41+
try {
42+
const parsed = JSON.parse(value) as Record<string, unknown>;
43+
if (parsed.schemaVersion !== 1) throw new Error('invalid schema');
44+
45+
const stableCheckpoint = parseStableCheckpoint(parsed);
46+
if (stableCheckpoint) return stableCheckpoint;
47+
48+
if (
49+
parsed.phase === 'deleting' &&
50+
Array.isArray(parsed.installationIds) &&
51+
parsed.installationIds.length > 0 &&
52+
parsed.installationIds.every(isInstallationId) &&
53+
parsed.next &&
54+
typeof parsed.next === 'object'
55+
) {
56+
const next = parseStableCheckpoint(parsed.next as Record<string, unknown>);
57+
if (next) {
58+
return {
59+
schemaVersion: 1,
60+
phase: 'deleting',
61+
installationIds: parsed.installationIds,
62+
next,
63+
};
64+
}
65+
}
66+
67+
throw new Error('invalid checkpoint fields');
68+
} catch {
69+
throw new Error('Malformed installation cleanup checkpoint');
70+
}
71+
}
72+
73+
function parseStableCheckpoint(
74+
parsed: Record<string, unknown>
75+
): InstallationStableCheckpoint | null {
76+
if (parsed.schemaVersion !== 1) return null;
77+
78+
if (parsed.phase === 'finalizing' && isAudit(parsed.audit)) {
79+
return {
80+
schemaVersion: 1,
81+
phase: 'finalizing',
82+
audit: parsed.audit,
83+
};
84+
}
85+
86+
if (
87+
parsed.phase === 'scanning' &&
88+
typeof parsed.cursor === 'string' &&
89+
parsed.cursor &&
90+
typeof parsed.startedAt === 'string' &&
91+
isCount(parsed.scannedCount) &&
92+
isCount(parsed.deletedCount) &&
93+
parsed.deletedCount <= parsed.scannedCount
94+
) {
95+
return {
96+
schemaVersion: 1,
97+
phase: 'scanning',
98+
cursor: parsed.cursor,
99+
startedAt: parsed.startedAt,
100+
scannedCount: parsed.scannedCount,
101+
deletedCount: parsed.deletedCount,
102+
};
103+
}
104+
105+
return null;
106+
}
107+
108+
function isAudit(value: unknown): value is InstallationCleanupAudit {
109+
if (!value || typeof value !== 'object') return false;
110+
const audit = value as Record<string, unknown>;
111+
return (
112+
audit.schemaVersion === 1 &&
113+
typeof audit.completedAt === 'string' &&
114+
isCount(audit.scannedCount) &&
115+
isCount(audit.activeCount) &&
116+
isCount(audit.deletedCount) &&
117+
audit.deletedCount <= audit.scannedCount
118+
);
119+
}
120+
121+
function isCount(value: unknown): value is number {
122+
return Number.isSafeInteger(value) && (value as number) >= 0;
123+
}
124+
125+
function isInstallationId(value: unknown): value is number {
126+
return Number.isSafeInteger(value) && (value as number) > 0;
127+
}

0 commit comments

Comments
 (0)