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 @@ |
|||||||
/** |
|
||||||
* 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 @@ |
|||||||
/** |
|
||||||
* 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 @@ |
|||||||
/** |
|
||||||
* 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