Browse Source
Nostr-Signature: ece894a60057bba46ebd4ac0dca2aca55ffce05e44671fe07b29516809fc86f6 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 176706a271659834e441ea5eab4bb1480667dad4468fe8315803284f4a183debf595523dd33d0d3cabe0c35013f4a72b9169b5f10afefaf8a82a721d8b0f3b08main
23 changed files with 2047 additions and 1519 deletions
File diff suppressed because it is too large
Load Diff
@ -1,543 +0,0 @@
@@ -1,543 +0,0 @@
|
||||
/** |
||||
* API endpoint for forking repositories |
||||
*/ |
||||
|
||||
import { json, error } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { KIND, type NostrEvent } from '$lib/types/nostr.js'; |
||||
import { getVisibility, getProjectRelays } from '$lib/utils/repo-visibility.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; |
||||
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; |
||||
import { existsSync } from 'fs'; |
||||
import { rm } from 'fs/promises'; |
||||
import { join, resolve } from 'path'; |
||||
import simpleGit from 'simple-git'; |
||||
import { isValidBranchName, validateRepoPath } from '$lib/utils/security.js'; |
||||
import { ResourceLimits } from '$lib/services/security/resource-limits.js'; |
||||
import { auditLogger } from '$lib/services/security/audit-logger.js'; |
||||
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; |
||||
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; |
||||
import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; |
||||
import logger from '$lib/services/logger.js'; |
||||
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; |
||||
import { eventCache } from '$lib/services/nostr/event-cache.js'; |
||||
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; |
||||
|
||||
import { repoManager, nostrClient, forkCountService } from '$lib/services/service-registry.js'; |
||||
|
||||
// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
|
||||
const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos'; |
||||
const repoRoot = resolve(repoRootEnv); |
||||
const resourceLimits = new ResourceLimits(repoRoot); |
||||
|
||||
/** |
||||
* Retry publishing an event with exponential backoff |
||||
* Attempts up to 3 times with delays: 1s, 2s, 4s |
||||
*/ |
||||
async function publishEventWithRetry( |
||||
event: NostrEvent, |
||||
relays: string[], |
||||
eventName: string, |
||||
maxAttempts: number = 3, |
||||
context?: string |
||||
): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { |
||||
let lastResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; |
||||
|
||||
// Extract context from event if available (for better logging)
|
||||
const eventId = event.id.slice(0, 8); |
||||
const logContext = context || `[event:${eventId}]`; |
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) { |
||||
logger.info({ logContext, eventName, attempt, maxAttempts }, `[Fork] Publishing ${eventName} - Attempt ${attempt}/${maxAttempts}...`); |
||||
|
||||
lastResult = await nostrClient.publishEvent(event, relays); |
||||
|
||||
if (lastResult.success.length > 0) { |
||||
logger.info({ logContext, eventName, successCount: lastResult.success.length, relays: lastResult.success }, `[Fork] ${eventName} published successfully`); |
||||
if (lastResult.failed.length > 0) { |
||||
logger.warn({ logContext, eventName, failed: lastResult.failed }, `[Fork] Some relays failed`); |
||||
} |
||||
return lastResult; |
||||
} |
||||
|
||||
if (attempt < maxAttempts) { |
||||
const delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
|
||||
logger.warn({ logContext, eventName, attempt, delayMs, failed: lastResult.failed }, `[Fork] ${eventName} failed on attempt ${attempt}. Retrying...`); |
||||
await new Promise(resolve => setTimeout(resolve, delayMs)); |
||||
} |
||||
} |
||||
|
||||
// All attempts failed
|
||||
logger.error({ logContext, eventName, maxAttempts, failed: lastResult?.failed }, `[Fork] ${eventName} failed after ${maxAttempts} attempts`); |
||||
return lastResult!; |
||||
} |
||||
|
||||
/** |
||||
* POST - Fork a repository |
||||
* Body: { userPubkey, forkName? } |
||||
*/ |
||||
export const POST: RequestHandler = async ({ params, request }) => { |
||||
const { npub, repo } = params; |
||||
|
||||
if (!npub || !repo) { |
||||
return error(400, 'Missing npub or repo parameter'); |
||||
} |
||||
|
||||
try { |
||||
const body = await request.json(); |
||||
const { userPubkey, forkName, localOnly } = body; |
||||
|
||||
if (!userPubkey) { |
||||
return error(401, 'Authentication required. Please provide userPubkey.'); |
||||
} |
||||
|
||||
// Validate localOnly parameter
|
||||
const isLocalOnly = localOnly === true; |
||||
|
||||
// Decode original repo owner npub
|
||||
let originalOwnerPubkey: string; |
||||
try { |
||||
originalOwnerPubkey = requireNpubHex(npub); |
||||
} catch { |
||||
return error(400, 'Invalid npub format'); |
||||
} |
||||
|
||||
// Decode user pubkey if needed (must be done before using it)
|
||||
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; |
||||
|
||||
// Convert to npub for resource check and path construction
|
||||
const userNpub = nip19.npubEncode(userPubkeyHex); |
||||
|
||||
// Determine fork name (use original name if not specified)
|
||||
const forkRepoName = forkName || repo; |
||||
|
||||
// Check if user has unlimited access (required for storing repos locally)
|
||||
const userLevel = getCachedUserLevel(userPubkeyHex); |
||||
if (!hasUnlimitedAccess(userLevel?.level)) { |
||||
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; |
||||
auditLogger.logRepoFork( |
||||
userPubkeyHex, |
||||
`${npub}/${repo}`, |
||||
`${userNpub}/${forkRepoName}`, |
||||
'failure', |
||||
'User does not have unlimited access' |
||||
); |
||||
return error(403, 'Repository creation requires unlimited access. Please verify you can write to at least one default Nostr relay.'); |
||||
} |
||||
|
||||
// Check resource limits before forking
|
||||
const resourceCheck = await resourceLimits.canCreateRepo(userNpub); |
||||
if (!resourceCheck.allowed) { |
||||
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; |
||||
auditLogger.logRepoFork( |
||||
userPubkeyHex, |
||||
`${npub}/${repo}`, |
||||
`${userNpub}/${forkRepoName}`, |
||||
'failure', |
||||
resourceCheck.reason |
||||
); |
||||
return error(403, resourceCheck.reason || 'Resource limit exceeded'); |
||||
} |
||||
|
||||
// Check if original repo exists
|
||||
const originalRepoPath = join(repoRoot, npub, `${repo}.git`); |
||||
// Security: Ensure resolved path is within repoRoot
|
||||
const originalPathValidation = validateRepoPath(originalRepoPath, repoRoot); |
||||
if (!originalPathValidation.valid) { |
||||
return error(403, originalPathValidation.error || 'Invalid repository path'); |
||||
} |
||||
if (!existsSync(originalRepoPath)) { |
||||
return error(404, 'Original repository not found'); |
||||
} |
||||
|
||||
// Get original repo announcement (case-insensitive) with caching
|
||||
const allAnnouncements = await fetchRepoAnnouncementsWithCache(nostrClient, originalOwnerPubkey, eventCache); |
||||
const originalAnnouncement = findRepoAnnouncement(allAnnouncements, repo); |
||||
|
||||
if (!originalAnnouncement) { |
||||
return error(404, 'Original repository announcement not found'); |
||||
} |
||||
|
||||
// Check if fork already exists
|
||||
const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); |
||||
// Security: Ensure resolved path is within repoRoot
|
||||
const forkPathValidation = validateRepoPath(forkRepoPath, repoRoot); |
||||
if (!forkPathValidation.valid) { |
||||
return error(403, forkPathValidation.error || 'Invalid fork repository path'); |
||||
} |
||||
if (existsSync(forkRepoPath)) { |
||||
return error(409, 'Fork already exists'); |
||||
} |
||||
|
||||
// Clone the repository using simple-git (safer than shell commands)
|
||||
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; |
||||
auditLogger.logRepoFork( |
||||
userPubkeyHex, |
||||
`${npub}/${repo}`, |
||||
`${userNpub}/${forkRepoName}`, |
||||
'success' |
||||
); |
||||
|
||||
const git = simpleGit(); |
||||
await git.clone(originalRepoPath, forkRepoPath, ['--bare']); |
||||
|
||||
// Invalidate resource limit cache after creating repo
|
||||
resourceLimits.invalidateCache(userNpub); |
||||
|
||||
// Create fork announcement
|
||||
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; |
||||
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
||||
const protocol = isLocalhost ? 'http' : 'https'; |
||||
const forkGitUrl = `${protocol}://${gitDomain}/${userNpub}/${forkRepoName}.git`; |
||||
|
||||
// Get Tor .onion URL if available
|
||||
const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js'); |
||||
const torOnionUrl = await getTorGitUrl(userNpub, forkRepoName); |
||||
|
||||
// Extract original clone URLs and earliest unique commit
|
||||
const originalCloneUrls = originalAnnouncement.tags |
||||
.filter(t => t[0] === 'clone') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string') |
||||
.filter(url => { |
||||
// Exclude our domain and .onion URLs (we'll add our own if available)
|
||||
if (url.includes(gitDomain)) return false; |
||||
if (url.includes('.onion')) return false; |
||||
return true; |
||||
}) as string[]; |
||||
|
||||
const earliestCommitTag = originalAnnouncement.tags.find(t => t[0] === 'r' && t[2] === 'euc'); |
||||
const earliestCommit = earliestCommitTag?.[1]; |
||||
|
||||
// Get original repo name and description
|
||||
const originalName = originalAnnouncement.tags.find(t => t[0] === 'name')?.[1] || repo; |
||||
const originalDescription = originalAnnouncement.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||
|
||||
// Build clone URLs for fork - NEVER include localhost, only include public domain or Tor .onion
|
||||
const forkCloneUrls: string[] = []; |
||||
|
||||
// Add our domain URL only if it's NOT localhost (explicitly check the URL)
|
||||
if (!isLocalhost && !forkGitUrl.includes('localhost') && !forkGitUrl.includes('127.0.0.1')) { |
||||
forkCloneUrls.push(forkGitUrl); |
||||
} |
||||
|
||||
// Add Tor .onion URL if available
|
||||
if (torOnionUrl) { |
||||
forkCloneUrls.push(torOnionUrl); |
||||
} |
||||
|
||||
// Add original clone URLs
|
||||
forkCloneUrls.push(...originalCloneUrls); |
||||
|
||||
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL
|
||||
if (isLocalhost && !torOnionUrl && originalCloneUrls.length === 0) { |
||||
return error(400, 'Cannot create fork with only localhost. The original repository must have at least one public clone URL, or you need to configure a Tor .onion address.'); |
||||
} |
||||
|
||||
// Preserve visibility and project-relay from original repo
|
||||
const originalVisibility = getVisibility(originalAnnouncement); |
||||
const originalProjectRelays = getProjectRelays(originalAnnouncement); |
||||
|
||||
// Build fork announcement tags
|
||||
// Use standardized fork tag: ['fork', '30617:pubkey:d-tag']
|
||||
const originalRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; |
||||
const tags: string[][] = [ |
||||
['d', forkRepoName], |
||||
['name', `${originalName} (fork)`], |
||||
['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`], |
||||
['clone', ...forkCloneUrls], |
||||
['relays', ...DEFAULT_NOSTR_RELAYS], |
||||
['fork', originalRepoTag], // Standardized fork tag format
|
||||
['p', originalOwnerPubkey], // Original owner
|
||||
]; |
||||
|
||||
// Local-only forks are always private and marked as synthetic
|
||||
if (isLocalOnly) { |
||||
tags.push(['visibility', 'private']); |
||||
tags.push(['local-only', 'true']); // Mark as synthetic/local-only
|
||||
} else { |
||||
// Preserve visibility from original repo (defaults to public if not set)
|
||||
if (originalVisibility !== 'public') { |
||||
tags.push(['visibility', originalVisibility]); |
||||
} |
||||
} |
||||
|
||||
// Preserve project-relay tags from original repo
|
||||
for (const relay of originalProjectRelays) { |
||||
tags.push(['project-relay', relay]); |
||||
} |
||||
|
||||
// Add earliest unique commit if available
|
||||
if (earliestCommit) { |
||||
tags.push(['r', earliestCommit, 'euc']); |
||||
} |
||||
|
||||
// Create fork announcement event
|
||||
const forkAnnouncementTemplate = { |
||||
kind: KIND.REPO_ANNOUNCEMENT, |
||||
pubkey: userPubkeyHex, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags |
||||
}; |
||||
|
||||
// Sign fork announcement
|
||||
const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate); |
||||
|
||||
// Security: Truncate npub in logs and create context (must be before use)
|
||||
const truncatedNpub = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub; |
||||
const truncatedOriginalNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub; |
||||
const context = `[${truncatedOriginalNpub}/${repo} → ${truncatedNpub}/${forkRepoName}]`; |
||||
|
||||
let publishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; |
||||
let ownershipPublishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; |
||||
let signedOwnershipEvent: NostrEvent | null = null; |
||||
|
||||
if (isLocalOnly) { |
||||
// Local-only fork: Skip publishing to Nostr relays
|
||||
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: true }, 'Creating local-only fork (not publishing to Nostr)'); |
||||
publishResult = { success: [], failed: [] }; |
||||
ownershipPublishResult = { success: [], failed: [] }; |
||||
|
||||
// For local-only forks, create a synthetic ownership event (not published)
|
||||
const ownershipService = new OwnershipTransferService([]); |
||||
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); |
||||
signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); |
||||
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Created synthetic ownership event for local-only fork'); |
||||
} else { |
||||
// Public fork: Publish to Nostr relays
|
||||
const { outbox } = await getUserRelays(userPubkeyHex, nostrClient); |
||||
const combinedRelays = combineRelays(outbox); |
||||
|
||||
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, relayCount: combinedRelays.length, relays: combinedRelays }, 'Starting fork process'); |
||||
|
||||
publishResult = await publishEventWithRetry( |
||||
signedForkAnnouncement, |
||||
combinedRelays, |
||||
'fork announcement', |
||||
3, |
||||
context |
||||
); |
||||
|
||||
if (publishResult.success.length === 0) { |
||||
// Clean up repo if announcement failed
|
||||
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: publishResult.failed }, 'Fork announcement failed after all retries. Cleaning up repository.'); |
||||
await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); |
||||
const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`; |
||||
return json({ |
||||
success: false, |
||||
error: 'Failed to publish fork announcement to relays after 3 attempts', |
||||
details: errorDetails, |
||||
eventName: 'fork announcement' |
||||
}, { status: 500 }); |
||||
} |
||||
|
||||
// Create and publish initial ownership proof (self-transfer event)
|
||||
// This MUST succeed for the fork to be valid - without it, there's no proof of ownership on Nostr
|
||||
const ownershipService = new OwnershipTransferService(combinedRelays); |
||||
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); |
||||
signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); |
||||
|
||||
ownershipPublishResult = await publishEventWithRetry( |
||||
signedOwnershipEvent, |
||||
combinedRelays, |
||||
'ownership transfer event', |
||||
3, |
||||
context |
||||
); |
||||
|
||||
if (ownershipPublishResult.success.length === 0) { |
||||
// Clean up repo if ownership proof failed
|
||||
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: ownershipPublishResult.failed }, 'Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.'); |
||||
await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); |
||||
|
||||
// Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof
|
||||
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Publishing deletion request for invalid fork announcement...'); |
||||
const deletionRequest = { |
||||
kind: KIND.DELETION_REQUEST, // NIP-09: Event Deletion Request
|
||||
pubkey: userPubkeyHex, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.', |
||||
tags: [ |
||||
['a', `${KIND.REPO_ANNOUNCEMENT}:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement
|
||||
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted
|
||||
] |
||||
}; |
||||
|
||||
const signedDeletionRequest = await signEventWithNIP07(deletionRequest); |
||||
const deletionResult = await publishEventWithRetry( |
||||
signedDeletionRequest, |
||||
combinedRelays, |
||||
'deletion request', |
||||
3, |
||||
context |
||||
); |
||||
|
||||
if (deletionResult.success.length > 0) { |
||||
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Deletion request published successfully'); |
||||
} else { |
||||
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: deletionResult.failed }, 'Failed to publish deletion request'); |
||||
} |
||||
|
||||
const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`; |
||||
return json({ |
||||
success: false, |
||||
error: 'Failed to publish ownership transfer event to relays after 3 attempts', |
||||
details: errorDetails, |
||||
eventName: 'ownership transfer event' |
||||
}, { status: 500 }); |
||||
} |
||||
} |
||||
|
||||
// Provision the fork repo (this will create verification file and include self-transfer)
|
||||
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: isLocalOnly }, 'Provisioning fork repository...'); |
||||
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent || undefined, false); |
||||
|
||||
// Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl
|
||||
try { |
||||
const { fileManager } = await import('$lib/services/service-registry.js'); |
||||
|
||||
// Save to repo if it exists locally (should exist after provisioning)
|
||||
if (fileManager.repoExists(userNpub, forkRepoName)) { |
||||
// Get worktree to save to repo-events.jsonl
|
||||
const defaultBranch = await fileManager.getDefaultBranch(userNpub, forkRepoName).catch(() => 'main'); |
||||
const repoPath = fileManager.getRepoPath(userNpub, forkRepoName); |
||||
const workDir = await fileManager.getWorktree(repoPath, defaultBranch, userNpub, forkRepoName); |
||||
|
||||
// Save to repo-events.jsonl
|
||||
await fileManager.saveRepoEventToWorktree(workDir, signedForkAnnouncement as NostrEvent, 'announcement').catch(err => { |
||||
logger.debug({ error: err }, 'Failed to save fork announcement to repo-events.jsonl'); |
||||
}); |
||||
|
||||
// Stage and commit the file
|
||||
const workGit = simpleGit(workDir); |
||||
await workGit.add(['nostr/repo-events.jsonl']); |
||||
await workGit.commit( |
||||
`Add fork repository announcement: ${signedForkAnnouncement.id.slice(0, 16)}...`, |
||||
['nostr/repo-events.jsonl'], |
||||
{ |
||||
'--author': `Nostr <${userPubkeyHex}@nostr>` |
||||
} |
||||
); |
||||
|
||||
// Clean up worktree
|
||||
await fileManager.removeWorktree(repoPath, workDir).catch(err => { |
||||
logger.debug({ error: err }, 'Failed to remove worktree after saving fork announcement'); |
||||
}); |
||||
} |
||||
} catch (err) { |
||||
// Log but don't fail - publishing to relays is more important
|
||||
logger.warn({ error: err, npub: userNpub, repo: forkRepoName }, 'Failed to save fork announcement to repo'); |
||||
} |
||||
|
||||
logger.info({ |
||||
operation: 'fork', |
||||
originalRepo: `${npub}/${repo}`, |
||||
forkRepo: `${userNpub}/${forkRepoName}`, |
||||
localOnly: isLocalOnly, |
||||
announcementId: signedForkAnnouncement.id, |
||||
ownershipTransferId: signedOwnershipEvent?.id, |
||||
announcementRelays: publishResult?.success.length || 0, |
||||
ownershipRelays: ownershipPublishResult?.success.length || 0 |
||||
}, 'Fork completed successfully'); |
||||
|
||||
const message = isLocalOnly |
||||
? 'Local-only fork created successfully! This fork is private and only exists on this server.' |
||||
: `Repository forked successfully! Published to ${publishResult?.success.length || 0} relay(s) for announcement and ${ownershipPublishResult?.success.length || 0} relay(s) for ownership proof.`; |
||||
|
||||
return json({ |
||||
success: true, |
||||
fork: { |
||||
npub: userNpub, |
||||
repo: forkRepoName, |
||||
url: forkGitUrl, |
||||
localOnly: isLocalOnly, |
||||
announcementId: signedForkAnnouncement.id, |
||||
ownershipTransferId: signedOwnershipEvent?.id, |
||||
publishedTo: isLocalOnly ? null : { |
||||
announcement: publishResult?.success.length || 0, |
||||
ownershipTransfer: ownershipPublishResult?.success.length || 0 |
||||
} |
||||
}, |
||||
message |
||||
}); |
||||
} catch (err) { |
||||
return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository'); |
||||
} |
||||
}; |
||||
|
||||
/** |
||||
* GET - Get fork information |
||||
* Returns whether this is a fork and what it's forked from |
||||
*/ |
||||
export const GET: RequestHandler = async ({ params }) => { |
||||
const { npub, repo } = params; |
||||
|
||||
if (!npub || !repo) { |
||||
return error(400, 'Missing npub or repo parameter'); |
||||
} |
||||
|
||||
try { |
||||
// Decode repo owner npub
|
||||
let ownerPubkey: string; |
||||
try { |
||||
ownerPubkey = requireNpubHex(npub); |
||||
} catch { |
||||
return error(400, 'Invalid npub format'); |
||||
} |
||||
|
||||
// Get repo announcement (case-insensitive) with caching
|
||||
const allAnnouncements = await fetchRepoAnnouncementsWithCache(nostrClient, ownerPubkey, eventCache); |
||||
const announcement = findRepoAnnouncement(allAnnouncements, repo); |
||||
|
||||
if (!announcement) { |
||||
return error(404, 'Repository announcement not found'); |
||||
} |
||||
|
||||
// announcement is already set above
|
||||
const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork'); |
||||
|
||||
// Get original repo reference
|
||||
const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)); |
||||
const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey); |
||||
|
||||
let originalRepo: { npub: string; repo: string } | null = null; |
||||
if (originalRepoTag && originalRepoTag[1]) { |
||||
const match = originalRepoTag[1].match(new RegExp(`^${KIND.REPO_ANNOUNCEMENT}:([a-f0-9]{64}):(.+)$`)); |
||||
if (match) { |
||||
const [, originalOwnerPubkey, originalRepoName] = match; |
||||
try { |
||||
const originalNpub = nip19.npubEncode(originalOwnerPubkey); |
||||
originalRepo = { npub: originalNpub, repo: originalRepoName }; |
||||
} catch { |
||||
// Invalid pubkey
|
||||
} |
||||
} |
||||
} |
||||
|
||||
// Get fork count for this repo
|
||||
let forkCount = 0; |
||||
if (!isFork && ownerPubkey && repo) { |
||||
try { |
||||
forkCount = await forkCountService.getForkCount(ownerPubkey, repo); |
||||
} catch (err) { |
||||
// Log but don't fail the request
|
||||
const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]'; |
||||
logger.warn({ error: err, npub, repo }, `[Fork] ${context} Failed to get fork count`); |
||||
} |
||||
} |
||||
|
||||
return json({ |
||||
isFork, |
||||
originalRepo, |
||||
forkCount |
||||
}); |
||||
} catch (err) { |
||||
return handleApiError(err, { operation: 'getForkInfo', npub, repo }, 'Failed to get fork information'); |
||||
} |
||||
}; |
||||
@ -1,181 +0,0 @@
@@ -1,181 +0,0 @@
|
||||
/** |
||||
* API endpoint for transferring repository ownership |
||||
*/ |
||||
|
||||
import { json, error } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
import { ownershipTransferService, nostrClient, fileManager } from '$lib/services/service-registry.js'; |
||||
import { combineRelays } from '$lib/config.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import { verifyEvent, nip19 } from 'nostr-tools'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; |
||||
import type { RepoRequestContext } from '$lib/utils/api-context.js'; |
||||
import type { RequestEvent } from '@sveltejs/kit'; |
||||
import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
/** |
||||
* GET - Get current owner and transfer history |
||||
*/ |
||||
export const GET: RequestHandler = createRepoGetHandler( |
||||
async (context: RepoRequestContext) => { |
||||
// Get current owner (may be different if transferred)
|
||||
const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo); |
||||
|
||||
// Fetch transfer events for history
|
||||
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`; |
||||
const transferEvents = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.OWNERSHIP_TRANSFER], |
||||
'#a': [repoTag], |
||||
limit: 100 |
||||
} |
||||
]); |
||||
|
||||
// Sort by created_at descending
|
||||
transferEvents.sort((a, b) => b.created_at - a.created_at); |
||||
|
||||
return json({ |
||||
originalOwner: context.repoOwnerPubkey, |
||||
currentOwner, |
||||
transferred: currentOwner !== context.repoOwnerPubkey, |
||||
transfers: transferEvents.map(event => { |
||||
const pTag = event.tags.find(t => t[0] === 'p'); |
||||
return { |
||||
eventId: event.id, |
||||
from: event.pubkey, |
||||
to: pTag?.[1] || 'unknown', |
||||
timestamp: event.created_at, |
||||
createdAt: new Date(event.created_at * 1000).toISOString() |
||||
}; |
||||
}) |
||||
}); |
||||
}, |
||||
{ operation: 'getOwnership', requireRepoAccess: false } // Ownership info is public
|
||||
); |
||||
|
||||
/** |
||||
* POST - Initiate ownership transfer |
||||
* Requires a pre-signed NIP-98 authenticated event from the current owner |
||||
*/ |
||||
export const POST: RequestHandler = withRepoValidation( |
||||
async ({ repoContext, requestContext, event }) => { |
||||
if (!requestContext.userPubkeyHex) { |
||||
throw handleApiError(new Error('Authentication required'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required'); |
||||
} |
||||
|
||||
const body = await event.request.json(); |
||||
const { transferEvent } = body; |
||||
|
||||
if (!transferEvent) { |
||||
return handleValidationError('Missing transferEvent in request body', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Verify the event is properly signed
|
||||
if (!transferEvent.sig || !transferEvent.id) { |
||||
throw handleValidationError('Invalid event: missing signature or ID', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
if (!verifyEvent(transferEvent)) { |
||||
throw handleValidationError('Invalid event signature', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Verify user is the current owner
|
||||
const canTransfer = await ownershipTransferService.canTransfer( |
||||
requestContext.userPubkeyHex, |
||||
repoContext.repoOwnerPubkey, |
||||
repoContext.repo |
||||
); |
||||
|
||||
if (!canTransfer) { |
||||
throw handleAuthorizationError('Only the current repository owner can transfer ownership', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Verify the transfer event is from the current owner
|
||||
if (transferEvent.pubkey !== requestContext.userPubkeyHex) { |
||||
throw handleAuthorizationError('Transfer event must be signed by the current owner', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Verify it's an ownership transfer event
|
||||
if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { |
||||
throw handleValidationError(`Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`, { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Verify the 'a' tag references this repo
|
||||
const aTag = transferEvent.tags.find(t => t[0] === 'a'); |
||||
const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${repoContext.repoOwnerPubkey}:${repoContext.repo}`; |
||||
if (!aTag || aTag[1] !== expectedRepoTag) { |
||||
throw handleValidationError("Transfer event 'a' tag does not match this repository", { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Get user's relays and publish
|
||||
const { outbox } = await getUserRelays(requestContext.userPubkeyHex, nostrClient); |
||||
const combinedRelays = combineRelays(outbox); |
||||
|
||||
const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays); |
||||
|
||||
if (result.success.length === 0) { |
||||
throw handleApiError(new Error('Failed to publish transfer event to any relays'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish transfer event to any relays'); |
||||
} |
||||
|
||||
// Save transfer event to repo (offline papertrail - step 1 requirement)
|
||||
try { |
||||
const transferEventContent = JSON.stringify(transferEvent, null, 2) + '\n'; |
||||
// Use consistent filename pattern: .nostr-ownership-transfer-{eventId}.json
|
||||
const transferFileName = `.nostr-ownership-transfer-${transferEvent.id}.json`; |
||||
|
||||
// Save to repo if it exists locally
|
||||
if (fileManager.repoExists(repoContext.npub, repoContext.repo)) { |
||||
// Get worktree to save to repo-events.jsonl
|
||||
const defaultBranch = await fileManager.getDefaultBranch(repoContext.npub, repoContext.repo).catch(() => 'main'); |
||||
const repoPath = fileManager.getRepoPath(repoContext.npub, repoContext.repo); |
||||
const workDir = await fileManager.getWorktree(repoPath, defaultBranch, repoContext.npub, repoContext.repo); |
||||
|
||||
// Save to repo-events.jsonl (standard file for easy analysis)
|
||||
await fileManager.saveRepoEventToWorktree(workDir, transferEvent as NostrEvent, 'transfer').catch(err => { |
||||
logger.debug({ error: err }, 'Failed to save transfer event to repo-events.jsonl'); |
||||
}); |
||||
|
||||
// Also save individual transfer file
|
||||
await fileManager.writeFile( |
||||
repoContext.npub, |
||||
repoContext.repo, |
||||
transferFileName, |
||||
transferEventContent, |
||||
`Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`, |
||||
'Nostr', |
||||
`${requestContext.userPubkeyHex}@nostr`, |
||||
defaultBranch |
||||
).catch(err => { |
||||
// Log but don't fail - publishing to relays is more important
|
||||
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo'); |
||||
}); |
||||
|
||||
// Clean up worktree
|
||||
await fileManager.removeWorktree(repoPath, workDir).catch(err => { |
||||
logger.debug({ error: err }, 'Failed to remove worktree after saving transfer event'); |
||||
}); |
||||
} else { |
||||
logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo'); |
||||
} |
||||
} catch (err) { |
||||
// Log but don't fail - publishing to relays is more important
|
||||
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo'); |
||||
} |
||||
|
||||
// Clear cache so new owner is recognized immediately
|
||||
ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo); |
||||
|
||||
return json({ |
||||
success: true, |
||||
event: transferEvent, |
||||
published: result, |
||||
message: 'Ownership transfer initiated successfully', |
||||
// Signal to client that page should refresh
|
||||
refresh: true |
||||
}); |
||||
}, |
||||
{ operation: 'transferOwnership', requireRepoAccess: false } // Override to check owner instead
|
||||
); |
||||
@ -1,343 +0,0 @@
@@ -1,343 +0,0 @@
|
||||
/** |
||||
* API endpoint for verifying repository ownership |
||||
*/ |
||||
|
||||
import { json, error } from '@sveltejs/kit'; |
||||
// @ts-ignore - SvelteKit generates this type
|
||||
import type { RequestHandler } from './$types'; |
||||
import { fileManager } from '$lib/services/service-registry.js'; |
||||
import { verifyRepositoryOwnership } from '$lib/services/nostr/repo-verification.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import { nostrClient } from '$lib/services/service-registry.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import { existsSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
import { decodeNpubToHex } from '$lib/utils/npub-utils.js'; |
||||
import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; |
||||
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; |
||||
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; |
||||
import { eventCache } from '$lib/services/nostr/event-cache.js'; |
||||
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; |
||||
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; |
||||
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||
import { AnnouncementManager } from '$lib/services/git/announcement-manager.js'; |
||||
import { extractRequestContext } from '$lib/utils/api-context.js'; |
||||
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; |
||||
import simpleGit from 'simple-git'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT |
||||
? process.env.GIT_REPO_ROOT |
||||
: '/repos'; |
||||
|
||||
export const GET: RequestHandler = createRepoGetHandler( |
||||
async (context: RepoRequestContext) => { |
||||
// Check if repository exists - verification doesn't require the repo to be cloned locally
|
||||
// We can verify ownership from Nostr events alone
|
||||
|
||||
// Fetch the repository announcement (case-insensitive) with caching
|
||||
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); |
||||
const announcement = findRepoAnnouncement(allEvents, context.repo); |
||||
|
||||
if (!announcement) { |
||||
return json({ |
||||
verified: false, |
||||
error: 'Repository announcement not found', |
||||
message: 'Could not find a NIP-34 repository announcement for this repository.' |
||||
}); |
||||
} |
||||
|
||||
// Extract clone URLs from announcement
|
||||
const cloneUrls: string[] = []; |
||||
for (const tag of announcement.tags) { |
||||
if (tag[0] === 'clone') { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
const url = tag[i]; |
||||
if (url && typeof url === 'string') { |
||||
cloneUrls.push(url); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Verify ownership for each clone separately
|
||||
// Ownership is determined by the most recent announcement file checked into each clone
|
||||
const cloneVerifications: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }> = []; |
||||
|
||||
// First, verify the local GitRepublic clone (if it exists)
|
||||
let localVerified = false; |
||||
let localOwner: string | null = null; |
||||
let localError: string | undefined; |
||||
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); |
||||
const repoExists = existsSync(repoPath); |
||||
|
||||
if (repoExists) { |
||||
// Repo is cloned - verify the announcement file matches
|
||||
try { |
||||
// Get current owner from the most recent announcement file in the repo
|
||||
localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo); |
||||
|
||||
if (localOwner) { |
||||
// Verify the announcement in nostr/repo-events.jsonl matches the announcement event
|
||||
try { |
||||
const repoEventsFile = await fileManager.getFileContent(context.npub, context.repo, 'nostr/repo-events.jsonl', 'HEAD'); |
||||
// Parse repo-events.jsonl and find the most recent announcement
|
||||
const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); |
||||
let repoAnnouncement: NostrEvent | null = null; |
||||
let latestTimestamp = 0; |
||||
|
||||
for (const line of lines) { |
||||
try { |
||||
const entry = JSON.parse(line); |
||||
if (entry.type === 'announcement' && entry.event && entry.timestamp) { |
||||
if (entry.timestamp > latestTimestamp) { |
||||
latestTimestamp = entry.timestamp; |
||||
repoAnnouncement = entry.event; |
||||
} |
||||
} |
||||
} catch { |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
if (repoAnnouncement) { |
||||
const verification = verifyRepositoryOwnership(announcement, JSON.stringify(repoAnnouncement)); |
||||
localVerified = verification.valid; |
||||
if (!verification.valid) { |
||||
localError = verification.error; |
||||
} |
||||
} else { |
||||
localVerified = false; |
||||
localError = 'No announcement found in nostr/repo-events.jsonl'; |
||||
} |
||||
} catch (err) { |
||||
localVerified = false; |
||||
localError = 'Announcement file not found in repository'; |
||||
} |
||||
} else { |
||||
localVerified = false; |
||||
localError = 'No announcement found in repository'; |
||||
} |
||||
} catch (err) { |
||||
localVerified = false; |
||||
localError = err instanceof Error ? err.message : 'Failed to verify local clone'; |
||||
} |
||||
} else { |
||||
// Repo is not cloned yet - verify from Nostr announcement alone
|
||||
// The announcement pubkey must match the repo owner
|
||||
if (announcement.pubkey === context.repoOwnerPubkey) { |
||||
localVerified = true; |
||||
localOwner = context.repoOwnerPubkey; |
||||
localError = undefined; |
||||
} else { |
||||
localVerified = false; |
||||
localOwner = announcement.pubkey; |
||||
localError = 'Announcement pubkey does not match repository owner'; |
||||
} |
||||
} |
||||
|
||||
// Add local clone verification
|
||||
const localUrl = cloneUrls.find(url => url.includes(context.npub) || url.includes(context.repoOwnerPubkey)); |
||||
if (localUrl) { |
||||
cloneVerifications.push({ |
||||
url: localUrl, |
||||
verified: localVerified, |
||||
ownerPubkey: localOwner, |
||||
error: localError |
||||
}); |
||||
} |
||||
|
||||
// For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files
|
||||
// This is a future enhancement - for now we only verify the local GitRepublic clone
|
||||
|
||||
// Overall verification: at least one clone must be verified
|
||||
const overallVerified = cloneVerifications.some(cv => cv.verified); |
||||
const verifiedClones = cloneVerifications.filter(cv => cv.verified); |
||||
const currentOwner = localOwner || context.repoOwnerPubkey; |
||||
|
||||
if (overallVerified) { |
||||
return json({ |
||||
verified: true, |
||||
announcementId: announcement.id, |
||||
ownerPubkey: currentOwner, |
||||
verificationMethod: 'announcement-file', |
||||
cloneVerifications: cloneVerifications.map(cv => ({ |
||||
url: cv.url, |
||||
verified: cv.verified, |
||||
ownerPubkey: cv.ownerPubkey, |
||||
error: cv.error |
||||
})), |
||||
message: `Repository ownership verified successfully for ${verifiedClones.length} clone(s)` |
||||
}); |
||||
} else { |
||||
return json({ |
||||
verified: false, |
||||
error: localError || 'Repository ownership verification failed', |
||||
announcementId: announcement.id, |
||||
verificationMethod: 'announcement-file', |
||||
cloneVerifications: cloneVerifications.map(cv => ({ |
||||
url: cv.url, |
||||
verified: cv.verified, |
||||
ownerPubkey: cv.ownerPubkey, |
||||
error: cv.error |
||||
})), |
||||
message: 'Repository ownership verification failed for all clones' |
||||
}); |
||||
} |
||||
}, |
||||
{ operation: 'verifyRepo', requireRepoExists: false, requireRepoAccess: false } // Verification is public, doesn't need repo to exist
|
||||
); |
||||
|
||||
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||
const announcementManager = new AnnouncementManager(repoRoot); |
||||
|
||||
export const POST: RequestHandler = createRepoPostHandler( |
||||
async (context: RepoRequestContext, event: RequestEvent) => { |
||||
const requestContext = extractRequestContext(event); |
||||
const userPubkeyHex = requestContext.userPubkeyHex; |
||||
|
||||
if (!userPubkeyHex) { |
||||
return error(401, 'Authentication required. Please provide userPubkey.'); |
||||
} |
||||
|
||||
// Check if user is a maintainer or the repository owner
|
||||
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo); |
||||
const isOwner = userPubkeyHex === context.repoOwnerPubkey; |
||||
if (!isMaintainer && !isOwner) { |
||||
return error(403, 'Only repository owners and maintainers can save announcements.'); |
||||
} |
||||
|
||||
// Check if repository is cloned
|
||||
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); |
||||
if (!existsSync(repoPath)) { |
||||
return error(404, 'Repository is not cloned locally. Please clone the repository first.'); |
||||
} |
||||
|
||||
// Fetch the repository announcement
|
||||
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); |
||||
const announcement = findRepoAnnouncement(allEvents, context.repo); |
||||
|
||||
if (!announcement) { |
||||
return error(404, 'Repository announcement not found'); |
||||
} |
||||
|
||||
try { |
||||
// Check if repository has any commits
|
||||
const git = simpleGit(repoPath); |
||||
let hasCommits = false; |
||||
// Use same default branch logic as repo-manager (master, or from env)
|
||||
let defaultBranch = process.env.DEFAULT_BRANCH || 'master'; |
||||
|
||||
try { |
||||
const commitCount = await git.raw(['rev-list', '--count', '--all']); |
||||
hasCommits = parseInt(commitCount.trim(), 10) > 0; |
||||
} catch { |
||||
// If we can't check, assume no commits
|
||||
hasCommits = false; |
||||
} |
||||
|
||||
// If repository has commits, get the default branch
|
||||
if (hasCommits) { |
||||
try { |
||||
defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); |
||||
} catch { |
||||
// Fallback to default if getDefaultBranch fails
|
||||
defaultBranch = process.env.DEFAULT_BRANCH || 'master'; |
||||
} |
||||
} |
||||
|
||||
// Get worktree for the default branch (worktree manager will create branch if needed)
|
||||
logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Getting worktree for announcement commit'); |
||||
const worktreePath = await fileManager.getWorktree(repoPath, defaultBranch, context.npub, context.repo); |
||||
|
||||
// Check if announcement already exists
|
||||
const hasAnnouncement = await announcementManager.hasAnnouncementInRepo(worktreePath, announcement.id); |
||||
|
||||
if (hasAnnouncement) { |
||||
// Announcement already exists, but we'll update it anyway to ensure it's the latest
|
||||
logger.debug({ npub: context.npub, repo: context.repo, eventId: announcement.id }, 'Announcement already exists, updating anyway'); |
||||
} |
||||
|
||||
// Save announcement to worktree
|
||||
const saved = await announcementManager.saveRepoEventToWorktree(worktreePath, announcement, 'announcement', false); |
||||
|
||||
if (!saved) { |
||||
return error(500, 'Failed to save announcement to repository'); |
||||
} |
||||
|
||||
// Stage the file
|
||||
const workGit = simpleGit(worktreePath); |
||||
await workGit.add('nostr/repo-events.jsonl'); |
||||
|
||||
// Get author info
|
||||
let authorName = await fetchUserName(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS); |
||||
let authorEmail = await fetchUserEmail(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS); |
||||
|
||||
if (!authorName) { |
||||
const { nip19 } = await import('nostr-tools'); |
||||
const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex); |
||||
authorName = npub.substring(0, 20); |
||||
} |
||||
if (!authorEmail) { |
||||
const { nip19 } = await import('nostr-tools'); |
||||
const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex); |
||||
authorEmail = `${npub.substring(0, 20)}@gitrepublic.web`; |
||||
} |
||||
|
||||
// Commit the announcement
|
||||
const commitMessage = `Verify repository ownership by committing repo announcement event\n\nEvent ID: ${announcement.id}`; |
||||
|
||||
// For empty repositories, ensure the branch is set up in the worktree
|
||||
if (!hasCommits) { |
||||
try { |
||||
// Check if branch exists in worktree
|
||||
const currentBranch = await workGit.revparse(['--abbrev-ref', 'HEAD']).catch(() => null); |
||||
if (!currentBranch || currentBranch === 'HEAD') { |
||||
// Branch doesn't exist, create orphan branch in worktree
|
||||
logger.debug({ npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Creating orphan branch in worktree'); |
||||
await workGit.raw(['checkout', '--orphan', defaultBranch]); |
||||
} else if (currentBranch !== defaultBranch) { |
||||
// Switch to the correct branch
|
||||
logger.debug({ npub: context.npub, repo: context.repo, currentBranch, targetBranch: defaultBranch }, 'Switching to target branch in worktree'); |
||||
await workGit.checkout(defaultBranch); |
||||
} |
||||
} catch (branchErr) { |
||||
logger.warn({ error: branchErr, npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Branch setup in worktree failed, attempting commit anyway'); |
||||
} |
||||
} |
||||
|
||||
logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Committing announcement file'); |
||||
await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], { |
||||
'--author': `${authorName} <${authorEmail}>` |
||||
}); |
||||
|
||||
// Verify commit was created
|
||||
const commitHash = await workGit.revparse(['HEAD']).catch(() => null); |
||||
if (!commitHash) { |
||||
throw new Error('Commit was created but HEAD is not pointing to a valid commit'); |
||||
} |
||||
logger.info({ npub: context.npub, repo: context.repo, commitHash, branch: defaultBranch }, 'Announcement committed successfully'); |
||||
|
||||
// Push to default branch (if there's a remote)
|
||||
try { |
||||
await workGit.push('origin', defaultBranch); |
||||
} catch (pushErr) { |
||||
// Push might fail if there's no remote, that's okay
|
||||
logger.debug({ error: pushErr, npub: context.npub, repo: context.repo }, 'Push failed (may not have remote)'); |
||||
} |
||||
|
||||
// Clean up worktree
|
||||
await fileManager.removeWorktree(repoPath, worktreePath); |
||||
|
||||
return json({ |
||||
success: true, |
||||
message: 'Repository announcement committed successfully. Verification should update shortly.', |
||||
announcementId: announcement.id |
||||
}); |
||||
} catch (err) { |
||||
logger.error({ error: err, npub: context.npub, repo: context.repo }, 'Failed to commit announcement for verification'); |
||||
return handleApiError(err, { operation: 'verifyRepoCommit', npub: context.npub, repo: context.repo }, 'Failed to commit announcement'); |
||||
} |
||||
}, |
||||
{ operation: 'verifyRepoCommit', requireRepoExists: true, requireRepoAccess: true } |
||||
); |
||||
Loading…
Reference in new issue