You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

293 lines
12 KiB

/**
* 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 {
// Get default branch
const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
// Get worktree for the default branch
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}`;
await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], {
'--author': `${authorName} <${authorEmail}>`
});
// 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 }
);