Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/drift-detection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/sync-repositories.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
189 changes: 168 additions & 21 deletions scripts/check-transfer-permissions.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,146 @@
#!/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)
//
// 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)
// appId is actually the token, privateKey is actually originRepo
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');
}
Expand All @@ -31,8 +156,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, {
Expand All @@ -43,9 +166,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',
Expand All @@ -54,7 +175,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,
Expand All @@ -65,7 +185,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' :
Expand All @@ -81,7 +200,6 @@ export async function checkTransferPermission(token, originRepo) {
};

} catch (error) {
// Network errors, JSON parsing errors, etc.
return {
hasPermission: false,
permissionLevel: 'error',
Expand All @@ -93,15 +211,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<string>} originRepos - Array of repository identifiers in format "owner/repo-name"
* @returns {Promise<Map<string, Object>>} 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);
}

Expand All @@ -113,37 +243,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 <owner/repo> [<owner/repo2> ...]');
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);
Expand Down
13 changes: 12 additions & 1 deletion scripts/sync-repositories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down