Skip to content

Commit 9de75e0

Browse files
authored
fix: use GitHub App authentication for transfer permission checks (#28)
* fix: use GitHub App authentication for transfer permission checks The permission check now uses the worlddriven-migrate app credentials to verify if the app is installed on the source repository, rather than checking if worlddrivenbot has collaborator access. This fixes the migration flow: 1. User creates PR adding repo to REPOSITORIES.md with Origin field 2. CI runs and fails (app not installed) 3. User installs worlddriven-migrate app on their repo 4. CI reruns and passes (app detected) 5. PR merges, sync workflow transfers the repository Changes: - check-transfer-permissions.js: Add app-based auth using JWT - sync-repositories.js: Pass app credentials to permission check - drift-detection.yml: Add MIGRATE_APP_ID/PRIVATE_KEY env vars - sync-repositories.yml: Add MIGRATE_APP_ID/PRIVATE_KEY env vars Requires secrets: MIGRATE_APP_ID, MIGRATE_APP_PRIVATE_KEY * fix: improve backward compatibility detection for legacy API calls
1 parent a93281a commit 9de75e0

4 files changed

Lines changed: 186 additions & 22 deletions

File tree

.github/workflows/drift-detection.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ jobs:
2727
id: drift
2828
env:
2929
WORLDDRIVEN_GITHUB_TOKEN: ${{ secrets.WORLDDRIVEN_GITHUB_TOKEN || github.token }}
30+
MIGRATE_APP_ID: ${{ secrets.MIGRATE_APP_ID }}
31+
MIGRATE_APP_PRIVATE_KEY: ${{ secrets.MIGRATE_APP_PRIVATE_KEY }}
3032
run: |
3133
set +e
3234
node scripts/detect-drift.js > drift-report.md 2>&1
@@ -39,6 +41,8 @@ jobs:
3941
id: sync
4042
env:
4143
WORLDDRIVEN_GITHUB_TOKEN: ${{ secrets.WORLDDRIVEN_GITHUB_TOKEN || github.token }}
44+
MIGRATE_APP_ID: ${{ secrets.MIGRATE_APP_ID }}
45+
MIGRATE_APP_PRIVATE_KEY: ${{ secrets.MIGRATE_APP_PRIVATE_KEY }}
4246
run: |
4347
set +e
4448
node scripts/sync-repositories.js > sync-preview.md 2>&1

.github/workflows/sync-repositories.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
id: sync
3232
env:
3333
WORLDDRIVEN_GITHUB_TOKEN: ${{ secrets.WORLDDRIVEN_GITHUB_TOKEN }}
34+
MIGRATE_APP_ID: ${{ secrets.MIGRATE_APP_ID }}
35+
MIGRATE_APP_PRIVATE_KEY: ${{ secrets.MIGRATE_APP_PRIVATE_KEY }}
3436
run: |
3537
set +e
3638
node scripts/sync-repositories.js --apply > sync-report.md 2>&1

scripts/check-transfer-permissions.js

Lines changed: 168 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,146 @@
11
#!/usr/bin/env node
22

33
/**
4-
* Check if worlddrivenbot has admin permission on a repository
4+
* Check if worlddriven-migrate app is installed on a repository
55
* Required for repository transfer automation
6+
*
7+
* The migrate app grants admin permission when installed, enabling transfers.
68
*/
79

10+
import crypto from 'crypto';
11+
812
const GITHUB_API_BASE = 'https://api.github.com';
913
const ORG_NAME = 'worlddriven';
1014

1115
/**
12-
* Check if the authenticated user (worlddrivenbot) has admin permission on the origin repository
16+
* Generate a JWT for GitHub App authentication
17+
* @param {string} appId - GitHub App ID
18+
* @param {string} privateKey - GitHub App private key (PEM format)
19+
* @returns {string} JWT token
20+
*/
21+
function generateAppJWT(appId, privateKey) {
22+
const now = Math.floor(Date.now() / 1000);
23+
const payload = {
24+
iat: now - 60, // Issued 60 seconds ago to account for clock drift
25+
exp: now + 600, // Expires in 10 minutes
26+
iss: appId,
27+
};
28+
29+
// Create JWT header and payload
30+
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
31+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
32+
33+
// Sign with private key
34+
const sign = crypto.createSign('RSA-SHA256');
35+
sign.update(`${header}.${body}`);
36+
const signature = sign.sign(privateKey, 'base64url');
37+
38+
return `${header}.${body}.${signature}`;
39+
}
40+
41+
/**
42+
* Check if the worlddriven-migrate app is installed on the origin repository
1343
*
14-
* @param {string} token - GitHub token (WORLDDRIVEN_GITHUB_TOKEN)
44+
* @param {string} appId - GitHub App ID (MIGRATE_APP_ID)
45+
* @param {string} privateKey - GitHub App private key (MIGRATE_APP_PRIVATE_KEY)
1546
* @param {string} originRepo - Repository in format "owner/repo-name"
1647
* @returns {Promise<{hasPermission: boolean, permissionLevel: string, details: string}>}
1748
*/
18-
export async function checkTransferPermission(token, originRepo) {
49+
export async function checkTransferPermission(appId, privateKey, originRepo) {
50+
// Support legacy call signature for backward compatibility
51+
// Old: checkTransferPermission(token, originRepo)
52+
// New: checkTransferPermission(appId, privateKey, originRepo)
53+
//
54+
// Detection: privateKey is a PEM key (starts with '-----BEGIN') for new signature,
55+
// or looks like a repo path (contains '/') or is empty/missing for old signature
56+
const isLegacyCall = !originRepo && (!privateKey || !privateKey.startsWith('-----BEGIN'));
57+
58+
if (isLegacyCall) {
59+
// Called with old signature: (token, originRepo)
60+
// appId is actually the token, privateKey is actually originRepo
61+
return checkTransferPermissionLegacy(appId, privateKey);
62+
}
63+
64+
if (!appId || !privateKey) {
65+
// No app credentials, try legacy token-based check
66+
const token = process.env.WORLDDRIVEN_GITHUB_TOKEN;
67+
if (token && originRepo) {
68+
return checkTransferPermissionLegacy(token, originRepo);
69+
}
70+
throw new Error('GitHub App credentials (MIGRATE_APP_ID and MIGRATE_APP_PRIVATE_KEY) are required');
71+
}
72+
73+
if (!originRepo || !originRepo.includes('/')) {
74+
throw new Error('Origin repository must be in format "owner/repo-name"');
75+
}
76+
77+
const [owner, repo] = originRepo.split('/');
78+
79+
if (!owner || !repo) {
80+
throw new Error('Invalid origin repository format');
81+
}
82+
83+
try {
84+
// Generate JWT to authenticate as the GitHub App
85+
const jwt = generateAppJWT(appId, privateKey);
86+
87+
// Check if the app is installed on the repository
88+
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/installation`;
89+
90+
const response = await fetch(url, {
91+
headers: {
92+
'Authorization': `Bearer ${jwt}`,
93+
'Accept': 'application/vnd.github+json',
94+
'X-GitHub-Api-Version': '2022-11-28',
95+
},
96+
});
97+
98+
if (response.status === 404) {
99+
// App is not installed on this repository
100+
return {
101+
hasPermission: false,
102+
permissionLevel: 'none',
103+
details: `❌ worlddriven-migrate app is not installed on ${originRepo}. Install at: https://github.com/apps/worlddriven-migrate`,
104+
};
105+
}
106+
107+
if (!response.ok) {
108+
const error = await response.text();
109+
return {
110+
hasPermission: false,
111+
permissionLevel: 'unknown',
112+
details: `Failed to check app installation: ${response.status} - ${error}`,
113+
};
114+
}
115+
116+
const data = await response.json();
117+
118+
// App is installed - check if it has admin permission
119+
const permissions = data.permissions || {};
120+
const hasAdmin = permissions.administration === 'write' || permissions.administration === 'read';
121+
122+
return {
123+
hasPermission: hasAdmin,
124+
permissionLevel: hasAdmin ? 'admin' : 'limited',
125+
installationId: data.id,
126+
details: hasAdmin
127+
? `✅ worlddriven-migrate app is installed on ${originRepo} with admin permission`
128+
: `⚠️ worlddriven-migrate app is installed on ${originRepo} but lacks admin permission`,
129+
};
130+
131+
} catch (error) {
132+
return {
133+
hasPermission: false,
134+
permissionLevel: 'error',
135+
details: `Error checking app installation: ${error.message}`,
136+
};
137+
}
138+
}
139+
140+
/**
141+
* Legacy token-based permission check (fallback)
142+
*/
143+
async function checkTransferPermissionLegacy(token, originRepo) {
19144
if (!token) {
20145
throw new Error('GitHub token is required');
21146
}
@@ -31,8 +156,6 @@ export async function checkTransferPermission(token, originRepo) {
31156
}
32157

33158
try {
34-
// Check the authenticated user's permission on the origin repository
35-
// The repo endpoint returns permissions for the authenticated user
36159
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}`;
37160

38161
const response = await fetch(url, {
@@ -43,9 +166,7 @@ export async function checkTransferPermission(token, originRepo) {
43166
},
44167
});
45168

46-
// Handle different response scenarios
47169
if (response.status === 404) {
48-
// Repository doesn't exist or user doesn't have any access
49170
return {
50171
hasPermission: false,
51172
permissionLevel: 'none',
@@ -54,7 +175,6 @@ export async function checkTransferPermission(token, originRepo) {
54175
}
55176

56177
if (!response.ok) {
57-
// Other errors (rate limit, auth issues, etc.)
58178
const error = await response.text();
59179
return {
60180
hasPermission: false,
@@ -65,7 +185,6 @@ export async function checkTransferPermission(token, originRepo) {
65185

66186
const data = await response.json();
67187

68-
// The repo response includes permissions object for the authenticated user
69188
const permissions = data.permissions || {};
70189
const hasPermission = permissions.admin === true;
71190
const permissionLevel = hasPermission ? 'admin' :
@@ -81,7 +200,6 @@ export async function checkTransferPermission(token, originRepo) {
81200
};
82201

83202
} catch (error) {
84-
// Network errors, JSON parsing errors, etc.
85203
return {
86204
hasPermission: false,
87205
permissionLevel: 'error',
@@ -93,15 +211,27 @@ export async function checkTransferPermission(token, originRepo) {
93211
/**
94212
* Check permissions for multiple repositories
95213
*
96-
* @param {string} token - GitHub token
214+
* @param {string} appId - GitHub App ID (or token for legacy)
215+
* @param {string} privateKey - GitHub App private key (or originRepos array for legacy)
97216
* @param {Array<string>} originRepos - Array of repository identifiers in format "owner/repo-name"
98217
* @returns {Promise<Map<string, Object>>} Map of origin repo to permission result
99218
*/
100-
export async function checkMultipleTransferPermissions(token, originRepos) {
219+
export async function checkMultipleTransferPermissions(appId, privateKey, originRepos) {
101220
const results = new Map();
102221

222+
// Support legacy call signature: (token, originRepos)
223+
if (Array.isArray(privateKey)) {
224+
originRepos = privateKey;
225+
const token = appId;
226+
for (const originRepo of originRepos) {
227+
const result = await checkTransferPermissionLegacy(token, originRepo);
228+
results.set(originRepo, result);
229+
}
230+
return results;
231+
}
232+
103233
for (const originRepo of originRepos) {
104-
const result = await checkTransferPermission(token, originRepo);
234+
const result = await checkTransferPermission(appId, privateKey, originRepo);
105235
results.set(originRepo, result);
106236
}
107237

@@ -113,37 +243,54 @@ export async function checkMultipleTransferPermissions(token, originRepos) {
113243
*/
114244
async function main() {
115245
const args = process.argv.slice(2);
246+
const appId = process.env.MIGRATE_APP_ID;
247+
const privateKey = process.env.MIGRATE_APP_PRIVATE_KEY;
116248
const token = process.env.WORLDDRIVEN_GITHUB_TOKEN;
117249

118-
if (!token) {
119-
console.error('❌ Error: WORLDDRIVEN_GITHUB_TOKEN environment variable is not set');
250+
// Prefer app-based auth, fall back to token
251+
const useAppAuth = appId && privateKey;
252+
253+
if (!useAppAuth && !token) {
254+
console.error('❌ Error: Either MIGRATE_APP_ID + MIGRATE_APP_PRIVATE_KEY or WORLDDRIVEN_GITHUB_TOKEN must be set');
120255
process.exit(1);
121256
}
122257

123258
if (args.length === 0) {
124259
console.error('Usage: check-transfer-permissions.js <owner/repo> [<owner/repo2> ...]');
125260
console.error('');
261+
console.error('Environment variables:');
262+
console.error(' MIGRATE_APP_ID + MIGRATE_APP_PRIVATE_KEY - GitHub App credentials (preferred)');
263+
console.error(' WORLDDRIVEN_GITHUB_TOKEN - Legacy token-based auth (fallback)');
264+
console.error('');
126265
console.error('Example:');
127266
console.error(' check-transfer-permissions.js TooAngel/worlddriven');
128267
process.exit(1);
129268
}
130269

131270
try {
132-
console.error(`Checking transfer permissions for ${args.length} repository(ies)...\n`);
271+
const authMethod = useAppAuth ? 'GitHub App (worlddriven-migrate)' : 'Token (legacy)';
272+
console.error(`Checking transfer permissions for ${args.length} repository(ies) using ${authMethod}...\n`);
273+
274+
const allResults = [];
133275

134276
for (const originRepo of args) {
135-
const result = await checkTransferPermission(token, originRepo);
277+
const result = useAppAuth
278+
? await checkTransferPermission(appId, privateKey, originRepo)
279+
: await checkTransferPermissionLegacy(token, originRepo);
280+
281+
allResults.push(result);
282+
136283
console.log(`${originRepo}:`);
137284
console.log(` Permission Level: ${result.permissionLevel}`);
138285
console.log(` Can Transfer: ${result.hasPermission ? '✅ Yes' : '❌ No'}`);
286+
if (result.installationId) {
287+
console.log(` Installation ID: ${result.installationId}`);
288+
}
139289
console.log(` Details: ${result.details}`);
140290
console.log('');
141291
}
142292

143293
// Exit with error if any repository doesn't have admin permission
144-
const allResults = await Promise.all(
145-
args.map(repo => checkTransferPermission(token, repo))
146-
);
147294
const allHavePermission = allResults.every(r => r.hasPermission);
148295

149296
process.exit(allHavePermission ? 0 : 1);

scripts/sync-repositories.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,18 @@ async function main() {
759759
if (reposWithOrigin.length > 0) {
760760
console.error('🔐 Checking transfer permissions...');
761761
const originRepos = reposWithOrigin.map(r => r.origin);
762-
transferPermissions = await checkMultipleTransferPermissions(token, originRepos);
762+
763+
// Use app-based auth if credentials are available
764+
const appId = process.env.MIGRATE_APP_ID;
765+
const privateKey = process.env.MIGRATE_APP_PRIVATE_KEY;
766+
767+
if (appId && privateKey) {
768+
console.error(' Using GitHub App authentication (worlddriven-migrate)');
769+
transferPermissions = await checkMultipleTransferPermissions(appId, privateKey, originRepos);
770+
} else {
771+
console.error(' Using legacy token authentication');
772+
transferPermissions = await checkMultipleTransferPermissions(token, originRepos);
773+
}
763774
}
764775

765776
// Detect drift

0 commit comments

Comments
 (0)