From a106bddaad341087cc7830d282cb0022823f6049 Mon Sep 17 00:00:00 2001 From: Tobias Wilken Date: Sun, 18 Jan 2026 07:34:41 +0100 Subject: [PATCH 1/2] 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 --- .github/workflows/drift-detection.yml | 4 + .github/workflows/sync-repositories.yml | 2 + scripts/check-transfer-permissions.js | 184 +++++++++++++++++++++--- scripts/sync-repositories.js | 13 +- 4 files changed, 181 insertions(+), 22 deletions(-) diff --git a/.github/workflows/drift-detection.yml b/.github/workflows/drift-detection.yml index 4949f9f..a5c15e9 100644 --- a/.github/workflows/drift-detection.yml +++ b/.github/workflows/drift-detection.yml @@ -27,6 +27,8 @@ jobs: id: drift env: WORLDDRIVEN_GITHUB_TOKEN: ${{ secrets.WORLDDRIVEN_GITHUB_TOKEN || github.token }} + MIGRATE_APP_ID: ${{ secrets.MIGRATE_APP_ID }} + MIGRATE_APP_PRIVATE_KEY: ${{ secrets.MIGRATE_APP_PRIVATE_KEY }} run: | set +e node scripts/detect-drift.js > drift-report.md 2>&1 @@ -39,6 +41,8 @@ jobs: id: sync env: WORLDDRIVEN_GITHUB_TOKEN: ${{ secrets.WORLDDRIVEN_GITHUB_TOKEN || github.token }} + MIGRATE_APP_ID: ${{ secrets.MIGRATE_APP_ID }} + MIGRATE_APP_PRIVATE_KEY: ${{ secrets.MIGRATE_APP_PRIVATE_KEY }} run: | set +e node scripts/sync-repositories.js > sync-preview.md 2>&1 diff --git a/.github/workflows/sync-repositories.yml b/.github/workflows/sync-repositories.yml index 84608a7..d371198 100644 --- a/.github/workflows/sync-repositories.yml +++ b/.github/workflows/sync-repositories.yml @@ -31,6 +31,8 @@ jobs: id: sync env: WORLDDRIVEN_GITHUB_TOKEN: ${{ secrets.WORLDDRIVEN_GITHUB_TOKEN }} + MIGRATE_APP_ID: ${{ secrets.MIGRATE_APP_ID }} + MIGRATE_APP_PRIVATE_KEY: ${{ secrets.MIGRATE_APP_PRIVATE_KEY }} run: | set +e node scripts/sync-repositories.js --apply > sync-report.md 2>&1 diff --git a/scripts/check-transfer-permissions.js b/scripts/check-transfer-permissions.js index 93788d8..556e455 100644 --- a/scripts/check-transfer-permissions.js +++ b/scripts/check-transfer-permissions.js @@ -1,21 +1,141 @@ #!/usr/bin/env node /** - * Check if worlddrivenbot has admin permission on a repository + * Check if worlddriven-migrate app is installed on a repository * Required for repository transfer automation + * + * The migrate app grants admin permission when installed, enabling transfers. */ +import crypto from 'crypto'; + const GITHUB_API_BASE = 'https://api.github.com'; const ORG_NAME = 'worlddriven'; /** - * Check if the authenticated user (worlddrivenbot) has admin permission on the origin repository + * Generate a JWT for GitHub App authentication + * @param {string} appId - GitHub App ID + * @param {string} privateKey - GitHub App private key (PEM format) + * @returns {string} JWT token + */ +function generateAppJWT(appId, privateKey) { + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, // Issued 60 seconds ago to account for clock drift + exp: now + 600, // Expires in 10 minutes + iss: appId, + }; + + // Create JWT header and payload + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + + // Sign with private key + const sign = crypto.createSign('RSA-SHA256'); + sign.update(`${header}.${body}`); + const signature = sign.sign(privateKey, 'base64url'); + + return `${header}.${body}.${signature}`; +} + +/** + * Check if the worlddriven-migrate app is installed on the origin repository * - * @param {string} token - GitHub token (WORLDDRIVEN_GITHUB_TOKEN) + * @param {string} appId - GitHub App ID (MIGRATE_APP_ID) + * @param {string} privateKey - GitHub App private key (MIGRATE_APP_PRIVATE_KEY) * @param {string} originRepo - Repository in format "owner/repo-name" * @returns {Promise<{hasPermission: boolean, permissionLevel: string, details: string}>} */ -export async function checkTransferPermission(token, originRepo) { +export async function checkTransferPermission(appId, privateKey, originRepo) { + // Support legacy call signature for backward compatibility + // Old: checkTransferPermission(token, originRepo) + // New: checkTransferPermission(appId, privateKey, originRepo) + if (!originRepo && privateKey && privateKey.includes('/')) { + // Called with old signature: (token, originRepo) + // Fall back to token-based check + return checkTransferPermissionLegacy(appId, privateKey); + } + + if (!appId || !privateKey) { + // No app credentials, try legacy token-based check + const token = process.env.WORLDDRIVEN_GITHUB_TOKEN; + if (token && originRepo) { + return checkTransferPermissionLegacy(token, originRepo); + } + throw new Error('GitHub App credentials (MIGRATE_APP_ID and MIGRATE_APP_PRIVATE_KEY) are required'); + } + + if (!originRepo || !originRepo.includes('/')) { + throw new Error('Origin repository must be in format "owner/repo-name"'); + } + + const [owner, repo] = originRepo.split('/'); + + if (!owner || !repo) { + throw new Error('Invalid origin repository format'); + } + + try { + // Generate JWT to authenticate as the GitHub App + const jwt = generateAppJWT(appId, privateKey); + + // Check if the app is installed on the repository + const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/installation`; + + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + + if (response.status === 404) { + // App is not installed on this repository + return { + hasPermission: false, + permissionLevel: 'none', + details: `❌ worlddriven-migrate app is not installed on ${originRepo}. Install at: https://github.com/apps/worlddriven-migrate`, + }; + } + + if (!response.ok) { + const error = await response.text(); + return { + hasPermission: false, + permissionLevel: 'unknown', + details: `Failed to check app installation: ${response.status} - ${error}`, + }; + } + + const data = await response.json(); + + // App is installed - check if it has admin permission + const permissions = data.permissions || {}; + const hasAdmin = permissions.administration === 'write' || permissions.administration === 'read'; + + return { + hasPermission: hasAdmin, + permissionLevel: hasAdmin ? 'admin' : 'limited', + installationId: data.id, + details: hasAdmin + ? `✅ worlddriven-migrate app is installed on ${originRepo} with admin permission` + : `⚠️ worlddriven-migrate app is installed on ${originRepo} but lacks admin permission`, + }; + + } catch (error) { + return { + hasPermission: false, + permissionLevel: 'error', + details: `Error checking app installation: ${error.message}`, + }; + } +} + +/** + * Legacy token-based permission check (fallback) + */ +async function checkTransferPermissionLegacy(token, originRepo) { if (!token) { throw new Error('GitHub token is required'); } @@ -31,8 +151,6 @@ export async function checkTransferPermission(token, originRepo) { } try { - // Check the authenticated user's permission on the origin repository - // The repo endpoint returns permissions for the authenticated user const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}`; const response = await fetch(url, { @@ -43,9 +161,7 @@ export async function checkTransferPermission(token, originRepo) { }, }); - // Handle different response scenarios if (response.status === 404) { - // Repository doesn't exist or user doesn't have any access return { hasPermission: false, permissionLevel: 'none', @@ -54,7 +170,6 @@ export async function checkTransferPermission(token, originRepo) { } if (!response.ok) { - // Other errors (rate limit, auth issues, etc.) const error = await response.text(); return { hasPermission: false, @@ -65,7 +180,6 @@ export async function checkTransferPermission(token, originRepo) { const data = await response.json(); - // The repo response includes permissions object for the authenticated user const permissions = data.permissions || {}; const hasPermission = permissions.admin === true; const permissionLevel = hasPermission ? 'admin' : @@ -81,7 +195,6 @@ export async function checkTransferPermission(token, originRepo) { }; } catch (error) { - // Network errors, JSON parsing errors, etc. return { hasPermission: false, permissionLevel: 'error', @@ -93,15 +206,27 @@ export async function checkTransferPermission(token, originRepo) { /** * Check permissions for multiple repositories * - * @param {string} token - GitHub token + * @param {string} appId - GitHub App ID (or token for legacy) + * @param {string} privateKey - GitHub App private key (or originRepos array for legacy) * @param {Array} originRepos - Array of repository identifiers in format "owner/repo-name" * @returns {Promise>} Map of origin repo to permission result */ -export async function checkMultipleTransferPermissions(token, originRepos) { +export async function checkMultipleTransferPermissions(appId, privateKey, originRepos) { const results = new Map(); + // Support legacy call signature: (token, originRepos) + if (Array.isArray(privateKey)) { + originRepos = privateKey; + const token = appId; + for (const originRepo of originRepos) { + const result = await checkTransferPermissionLegacy(token, originRepo); + results.set(originRepo, result); + } + return results; + } + for (const originRepo of originRepos) { - const result = await checkTransferPermission(token, originRepo); + const result = await checkTransferPermission(appId, privateKey, originRepo); results.set(originRepo, result); } @@ -113,37 +238,54 @@ export async function checkMultipleTransferPermissions(token, originRepos) { */ async function main() { const args = process.argv.slice(2); + const appId = process.env.MIGRATE_APP_ID; + const privateKey = process.env.MIGRATE_APP_PRIVATE_KEY; const token = process.env.WORLDDRIVEN_GITHUB_TOKEN; - if (!token) { - console.error('❌ Error: WORLDDRIVEN_GITHUB_TOKEN environment variable is not set'); + // Prefer app-based auth, fall back to token + const useAppAuth = appId && privateKey; + + if (!useAppAuth && !token) { + console.error('❌ Error: Either MIGRATE_APP_ID + MIGRATE_APP_PRIVATE_KEY or WORLDDRIVEN_GITHUB_TOKEN must be set'); process.exit(1); } if (args.length === 0) { console.error('Usage: check-transfer-permissions.js [ ...]'); console.error(''); + console.error('Environment variables:'); + console.error(' MIGRATE_APP_ID + MIGRATE_APP_PRIVATE_KEY - GitHub App credentials (preferred)'); + console.error(' WORLDDRIVEN_GITHUB_TOKEN - Legacy token-based auth (fallback)'); + console.error(''); console.error('Example:'); console.error(' check-transfer-permissions.js TooAngel/worlddriven'); process.exit(1); } try { - console.error(`Checking transfer permissions for ${args.length} repository(ies)...\n`); + const authMethod = useAppAuth ? 'GitHub App (worlddriven-migrate)' : 'Token (legacy)'; + console.error(`Checking transfer permissions for ${args.length} repository(ies) using ${authMethod}...\n`); + + const allResults = []; for (const originRepo of args) { - const result = await checkTransferPermission(token, originRepo); + const result = useAppAuth + ? await checkTransferPermission(appId, privateKey, originRepo) + : await checkTransferPermissionLegacy(token, originRepo); + + allResults.push(result); + console.log(`${originRepo}:`); console.log(` Permission Level: ${result.permissionLevel}`); console.log(` Can Transfer: ${result.hasPermission ? '✅ Yes' : '❌ No'}`); + if (result.installationId) { + console.log(` Installation ID: ${result.installationId}`); + } console.log(` Details: ${result.details}`); console.log(''); } // Exit with error if any repository doesn't have admin permission - const allResults = await Promise.all( - args.map(repo => checkTransferPermission(token, repo)) - ); const allHavePermission = allResults.every(r => r.hasPermission); process.exit(allHavePermission ? 0 : 1); diff --git a/scripts/sync-repositories.js b/scripts/sync-repositories.js index 6ca303b..992a4ac 100755 --- a/scripts/sync-repositories.js +++ b/scripts/sync-repositories.js @@ -759,7 +759,18 @@ async function main() { if (reposWithOrigin.length > 0) { console.error('🔐 Checking transfer permissions...'); const originRepos = reposWithOrigin.map(r => r.origin); - transferPermissions = await checkMultipleTransferPermissions(token, originRepos); + + // Use app-based auth if credentials are available + const appId = process.env.MIGRATE_APP_ID; + const privateKey = process.env.MIGRATE_APP_PRIVATE_KEY; + + if (appId && privateKey) { + console.error(' Using GitHub App authentication (worlddriven-migrate)'); + transferPermissions = await checkMultipleTransferPermissions(appId, privateKey, originRepos); + } else { + console.error(' Using legacy token authentication'); + transferPermissions = await checkMultipleTransferPermissions(token, originRepos); + } } // Detect drift From 76d4bf64f1905eb73071220d422c7f3e8a59dc12 Mon Sep 17 00:00:00 2001 From: Tobias Wilken Date: Sun, 18 Jan 2026 07:38:34 +0100 Subject: [PATCH 2/2] fix: improve backward compatibility detection for legacy API calls --- scripts/check-transfer-permissions.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/check-transfer-permissions.js b/scripts/check-transfer-permissions.js index 556e455..ba4c017 100644 --- a/scripts/check-transfer-permissions.js +++ b/scripts/check-transfer-permissions.js @@ -50,9 +50,14 @@ export async function checkTransferPermission(appId, privateKey, originRepo) { // Support legacy call signature for backward compatibility // Old: checkTransferPermission(token, originRepo) // New: checkTransferPermission(appId, privateKey, originRepo) - if (!originRepo && privateKey && privateKey.includes('/')) { + // + // Detection: privateKey is a PEM key (starts with '-----BEGIN') for new signature, + // or looks like a repo path (contains '/') or is empty/missing for old signature + const isLegacyCall = !originRepo && (!privateKey || !privateKey.startsWith('-----BEGIN')); + + if (isLegacyCall) { // Called with old signature: (token, originRepo) - // Fall back to token-based check + // appId is actually the token, privateKey is actually originRepo return checkTransferPermissionLegacy(appId, privateKey); }