diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 935e302..a035e06 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -11,12 +11,18 @@ content?: string; language?: 'markdown' | 'asciidoc' | 'text'; onChange?: (value: string) => void; + onSelection?: (selectedText: string, startLine: number, endLine: number, startPos: number, endPos: number) => void; + readOnly?: boolean; + highlights?: Array<{ id: string; startLine: number; endLine: number; content: string }>; } let { content = $bindable(''), language = $bindable('text'), - onChange = () => {} + onChange = () => {}, + onSelection = () => {}, + readOnly = false, + highlights = [] }: Props = $props(); let editorView: EditorView | null = null; @@ -44,7 +50,26 @@ const newContent = update.state.doc.toString(); onChange(newContent); } - }) + + // Handle text selection + if (update.selectionSet && !readOnly) { + const selection = update.state.selection.main; + if (!selection.empty) { + const selectedText = update.state.doc.sliceString(selection.from, selection.to); + const startLine = update.state.doc.lineAt(selection.from); + const endLine = update.state.doc.lineAt(selection.to); + + onSelection( + selectedText, + startLine.number, + endLine.number, + selection.from, + selection.to + ); + } + } + }), + EditorView.editable.of(!readOnly) ] }); diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte new file mode 100644 index 0000000..9f7927e --- /dev/null +++ b/src/lib/components/PRDetail.svelte @@ -0,0 +1,661 @@ + + +
+
+

{pr.subject}

+
+ + {pr.status} + + {#if pr.commitId} + Commit: {pr.commitId.slice(0, 7)} + {/if} + Created {new Date(pr.created_at * 1000).toLocaleString()} +
+
+ +
+
+ {@html pr.content.replace(/\n/g, '
')} +
+
+ + {#if error} +
{error}
+ {/if} + +
+
+

Changes

+ {#if loadingDiff} +
Loading diff...
+ {:else if prDiff} +
+ +
+ {:else} +
No diff available
+ {/if} +
+ +
+
+

Highlights & Comments

+ {#if userPubkey} + + {/if} +
+ + {#if loading} +
Loading highlights...
+ {:else} + + {#each comments as comment} +
+
+ {formatPubkey(comment.pubkey)} + {new Date(comment.created_at * 1000).toLocaleString()} +
+
{comment.content}
+ {#if userPubkey} + + {/if} +
+ {/each} + + + {#each highlights as highlight} +
+
+ {formatPubkey(highlight.pubkey)} + {new Date(highlight.created_at * 1000).toLocaleString()} + {#if highlight.file} + {highlight.file} + {/if} + {#if highlight.lineStart} + Lines {highlight.lineStart}-{highlight.lineEnd} + {/if} +
+
+
{highlight.highlightedContent}
+
+ {#if highlight.comment} +
{highlight.comment}
+ {/if} + + + {#if highlight.comments && highlight.comments.length > 0} +
+ {#each highlight.comments as comment} +
+
+ {formatPubkey(comment.pubkey)} + {new Date(comment.created_at * 1000).toLocaleString()} +
+
{comment.content}
+ {#if userPubkey} + + {/if} +
+ {/each} +
+ {/if} + + {#if userPubkey} + + {/if} +
+ {/each} + + {#if highlights.length === 0 && comments.length === 0} +
No highlights or comments yet
+ {/if} + {/if} +
+
+
+ + +{#if showHighlightDialog} + +{/if} + + +{#if showCommentDialog} + +{/if} + + diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 4ade59e..35f6c77 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -48,8 +48,12 @@ export class RepoManager { /** * Create a bare git repository from a NIP-34 repo announcement + * + * @param event - The repo announcement event + * @param selfTransferEvent - Optional self-transfer event to include in initial commit + * @param isExistingRepo - Whether this is an existing repo being added to the server */ - async provisionRepo(event: NostrEvent): Promise { + async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false): Promise { const cloneUrls = this.extractCloneUrls(event); const domainUrl = cloneUrls.find(url => url.includes(this.domain)); @@ -68,19 +72,34 @@ export class RepoManager { mkdirSync(repoDir, { recursive: true }); } + // Check if repo already exists + const repoExists = existsSync(repoPath.fullPath); + + // If there are other clone URLs, sync from them first (for existing repos) + const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); + if (otherUrls.length > 0 && repoExists) { + // For existing repos, sync first to get the latest state + await this.syncFromRemotes(repoPath.fullPath, otherUrls); + } + // Create bare repository if it doesn't exist - const isNewRepo = !existsSync(repoPath.fullPath); + const isNewRepo = !repoExists; if (isNewRepo) { await execAsync(`git init --bare "${repoPath.fullPath}"`); - // Create verification file in the repository - await this.createVerificationFile(repoPath.fullPath, event); - } - - // If there are other clone URLs, sync from them - const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); - if (otherUrls.length > 0) { - await this.syncFromRemotes(repoPath.fullPath, otherUrls); + // Create verification file and self-transfer event in the repository + await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent); + + // If there are other clone URLs, sync from them after creating the repo + if (otherUrls.length > 0) { + await this.syncFromRemotes(repoPath.fullPath, otherUrls); + } + } else if (isExistingRepo && selfTransferEvent) { + // For existing repos, we might want to add the self-transfer event + // But we should be careful not to overwrite existing history + // For now, we'll just ensure the verification file exists + // The self-transfer event should already be published to relays + console.log(`Existing repo ${repoPath.fullPath} - self-transfer event should be published to relays`); } } @@ -215,10 +234,10 @@ export class RepoManager { } /** - * Create verification file in a new repository + * Create verification file and self-transfer event in a new repository * This proves the repository is owned by the announcement author */ - private async createVerificationFile(repoPath: string, event: NostrEvent): Promise { + private async createVerificationFile(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise { try { // Create a temporary working directory const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp'; @@ -242,13 +261,40 @@ export class RepoManager { const verificationPath = join(workDir, VERIFICATION_FILE_PATH); writeFileSync(verificationPath, verificationContent, 'utf-8'); - // Commit the verification file + // If self-transfer event is provided, include it in the commit + const filesToAdd = [VERIFICATION_FILE_PATH]; + if (selfTransferEvent) { + const selfTransferPath = join(workDir, '.nostr-ownership-transfer'); + const isTemplate = !selfTransferEvent.sig || !selfTransferEvent.id; + + const selfTransferContent = JSON.stringify({ + eventId: selfTransferEvent.id || '(unsigned - needs owner signature)', + pubkey: selfTransferEvent.pubkey, + signature: selfTransferEvent.sig || '(unsigned - needs owner signature)', + timestamp: selfTransferEvent.created_at, + kind: selfTransferEvent.kind, + content: selfTransferEvent.content, + tags: selfTransferEvent.tags, + ...(isTemplate ? { + _note: 'This is a template. The owner must sign and publish this event to relays for it to be valid.', + _instructions: 'To publish: 1. Sign this event with your private key, 2. Publish to relays using your Nostr client' + } : {}) + }, null, 2) + '\n'; + writeFileSync(selfTransferPath, selfTransferContent, 'utf-8'); + filesToAdd.push('.nostr-ownership-transfer'); + } + + // Commit the verification file and self-transfer event const workGit: SimpleGit = simpleGit(workDir); - await workGit.add(VERIFICATION_FILE_PATH); + await workGit.add(filesToAdd); // Use the event timestamp for commit date const commitDate = new Date(event.created_at * 1000).toISOString(); - await workGit.commit('Add Nostr repository verification file', [VERIFICATION_FILE_PATH], { + const commitMessage = selfTransferEvent + ? 'Add Nostr repository verification and initial ownership proof' + : 'Add Nostr repository verification file'; + + await workGit.commit(commitMessage, filesToAdd, { '--author': `Nostr <${event.pubkey}@nostr>`, '--date': commitDate }); @@ -276,4 +322,40 @@ export class RepoManager { if (!match) return null; return { repoName: match[1] }; } + + /** + * Check if a repository already has a verification file + * Used to determine if this is a truly new repo or an existing one being added + */ + async hasVerificationFile(repoPath: string): Promise { + if (!this.repoExists(repoPath)) { + return false; + } + + try { + const git: SimpleGit = simpleGit(); + const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp'; + const workDir = join(repoPath, '..', `${repoName}.check`); + const { rm, mkdir } = await import('fs/promises'); + + // Clean up if exists + if (existsSync(workDir)) { + await rm(workDir, { recursive: true, force: true }); + } + await mkdir(workDir, { recursive: true }); + + // Try to clone and check for verification file + await git.clone(repoPath, workDir); + const verificationPath = join(workDir, VERIFICATION_FILE_PATH); + const hasFile = existsSync(verificationPath); + + // Clean up + await rm(workDir, { recursive: true, force: true }); + + return hasFile; + } catch { + // If we can't check, assume it doesn't have one + return false; + } + } } diff --git a/src/lib/services/nostr/highlights-service.ts b/src/lib/services/nostr/highlights-service.ts new file mode 100644 index 0000000..3c51468 --- /dev/null +++ b/src/lib/services/nostr/highlights-service.ts @@ -0,0 +1,384 @@ +/** + * Service for managing NIP-84 Highlights (kind 9802) + * Used for code selections and comments in pull requests + */ + +import { NostrClient } from './nostr-client.js'; +import { KIND } from '../../types/nostr.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { verifyEvent } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; + +export interface Highlight extends NostrEvent { + kind: typeof KIND.HIGHLIGHT; + highlightedContent: string; + sourceUrl?: string; + sourceEventId?: string; + sourceEventAddress?: string; + context?: string; + authors?: Array<{ pubkey: string; role?: string }>; + comment?: string; // If present, this is a quote highlight + file?: string; + lineStart?: number; + lineEnd?: number; +} + +export interface HighlightWithComments extends Highlight { + comments: Comment[]; +} + +export interface Comment extends NostrEvent { + kind: typeof KIND.COMMENT; + rootKind: number; + parentKind: number; + rootPubkey?: string; + parentPubkey?: string; +} + +/** + * Service for managing highlights and comments + */ +export class HighlightsService { + private nostrClient: NostrClient; + private relays: string[]; + + constructor(relays: string[] = []) { + this.relays = relays; + this.nostrClient = new NostrClient(relays); + } + + /** + * Get repository announcement address (a tag format) + */ + private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { + return `30617:${repoOwnerPubkey}:${repoId}`; + } + + /** + * Get PR address (a tag format for PR) + */ + private getPRAddress(prId: string, prAuthor: string, repoOwnerPubkey: string, repoId: string): string { + return `1618:${prAuthor}:${repoId}`; + } + + /** + * Fetch highlights for a pull request + */ + async getHighlightsForPR( + prId: string, + prAuthor: string, + repoOwnerPubkey: string, + repoId: string + ): Promise { + const prAddress = this.getPRAddress(prId, prAuthor, repoOwnerPubkey, repoId); + + // Fetch highlights that reference this PR + const highlights = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.HIGHLIGHT], + '#a': [prAddress], + limit: 100 + } + ]) as Highlight[]; + + // Also fetch highlights that reference the PR by event ID + const highlightsByEvent = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.HIGHLIGHT], + '#e': [prId], + limit: 100 + } + ]) as Highlight[]; + + // Combine and deduplicate + const allHighlights = [...highlights, ...highlightsByEvent]; + const uniqueHighlights = new Map(); + for (const highlight of allHighlights) { + if (!uniqueHighlights.has(highlight.id) || highlight.created_at > uniqueHighlights.get(highlight.id)!.created_at) { + uniqueHighlights.set(highlight.id, highlight); + } + } + + // Parse highlights + const parsedHighlights: Highlight[] = []; + for (const event of Array.from(uniqueHighlights.values())) { + const highlight = this.parseHighlight(event); + if (highlight) { + parsedHighlights.push(highlight); + } + } + + // Fetch comments for each highlight + const highlightsWithComments: HighlightWithComments[] = []; + for (const highlight of parsedHighlights) { + const comments = await this.getCommentsForHighlight(highlight.id); + highlightsWithComments.push({ + ...highlight, + comments + }); + } + + // Sort by created_at descending (newest first) + highlightsWithComments.sort((a, b) => b.created_at - a.created_at); + + return highlightsWithComments; + } + + /** + * Parse a highlight event + */ + private parseHighlight(event: NostrEvent): Highlight | null { + if (event.kind !== KIND.HIGHLIGHT) { + return null; + } + + if (!verifyEvent(event)) { + return null; + } + + // Extract source references + const aTag = event.tags.find(t => t[0] === 'a'); + const eTag = event.tags.find(t => t[0] === 'e'); + const rTag = event.tags.find(t => t[0] === 'r' && !t[2]?.includes('mention')); + const contextTag = event.tags.find(t => t[0] === 'context'); + const commentTag = event.tags.find(t => t[0] === 'comment'); + + // Extract authors + const authors: Array<{ pubkey: string; role?: string }> = []; + for (const tag of event.tags) { + if (tag[0] === 'p' && !tag[2]?.includes('mention')) { + let pubkey = tag[1]; + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkey = decoded.data as string; + } + } catch { + // Assume it's already hex + } + authors.push({ + pubkey, + role: tag[3] // role is in 4th position + }); + } + } + + // Extract file path and line numbers + const fileTag = event.tags.find(t => t[0] === 'file'); + const lineStartTag = event.tags.find(t => t[0] === 'line-start'); + const lineEndTag = event.tags.find(t => t[0] === 'line-end'); + + return { + ...event, + kind: KIND.HIGHLIGHT, + highlightedContent: event.content, + sourceEventAddress: aTag?.[1], + sourceEventId: eTag?.[1], + sourceUrl: rTag?.[1], + context: contextTag?.[1], + authors: authors.length > 0 ? authors : undefined, + comment: commentTag?.[1], + file: fileTag?.[1], + lineStart: lineStartTag ? parseInt(lineStartTag[1]) : undefined, + lineEnd: lineEndTag ? parseInt(lineEndTag[1]) : undefined + }; + } + + /** + * Get comments for a highlight or PR + */ + async getCommentsForHighlight(highlightId: string): Promise { + const comments = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.COMMENT], + '#e': [highlightId], + limit: 100 + } + ]) as NostrEvent[]; + + const parsedComments: Comment[] = []; + for (const event of comments) { + if (!verifyEvent(event)) { + continue; + } + + // Parse NIP-22 comment structure + const kTag = event.tags.find(t => t[0] === 'k'); // Parent kind + const KTag = event.tags.find(t => t[0] === 'K'); // Root kind + const pTag = event.tags.find(t => t[0] === 'p'); // Parent author + const PTag = event.tags.find(t => t[0] === 'P'); // Root author + + parsedComments.push({ + ...event, + kind: KIND.COMMENT, + rootKind: KTag ? parseInt(KTag[1]) : 0, + parentKind: kTag ? parseInt(kTag[1]) : 0, + rootPubkey: PTag?.[1], + parentPubkey: pTag?.[1] + }); + } + + // Sort by created_at ascending (oldest first) + parsedComments.sort((a, b) => a.created_at - b.created_at); + + return parsedComments; + } + + /** + * Get comments for a pull request + */ + async getCommentsForPR(prId: string): Promise { + const comments = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.COMMENT], + '#E': [prId], // Root event (uppercase E) + limit: 100 + } + ]) as NostrEvent[]; + + const parsedComments: Comment[] = []; + for (const event of comments) { + if (!verifyEvent(event)) { + continue; + } + + const kTag = event.tags.find(t => t[0] === 'k'); + const KTag = event.tags.find(t => t[0] === 'K'); + const pTag = event.tags.find(t => t[0] === 'p'); + const PTag = event.tags.find(t => t[0] === 'P'); + + parsedComments.push({ + ...event, + kind: KIND.COMMENT, + rootKind: KTag ? parseInt(KTag[1]) : 0, + parentKind: kTag ? parseInt(kTag[1]) : 0, + rootPubkey: PTag?.[1], + parentPubkey: pTag?.[1] + }); + } + + parsedComments.sort((a, b) => a.created_at - b.created_at); + return parsedComments; + } + + /** + * Create a highlight event template + * + * @param highlightedContent - The selected code/text content + * @param prId - Pull request event ID + * @param prAuthor - PR author pubkey + * @param repoOwnerPubkey - Repository owner pubkey + * @param repoId - Repository identifier + * @param filePath - Path to the file being highlighted + * @param lineStart - Starting line number (optional) + * @param lineEnd - Ending line number (optional) + * @param context - Surrounding context (optional) + * @param comment - Comment text (optional, creates quote highlight) + */ + createHighlightEvent( + highlightedContent: string, + prId: string, + prAuthor: string, + repoOwnerPubkey: string, + repoId: string, + filePath?: string, + lineStart?: number, + lineEnd?: number, + context?: string, + comment?: string + ): Omit { + const prAddress = `1618:${prAuthor}:${repoId}`; + + const tags: string[][] = [ + ['a', prAddress], // Reference to PR + ['e', prId], // PR event ID + ['P', prAuthor], // PR author + ['K', KIND.PULL_REQUEST.toString()], // Root kind + ]; + + // Add file path and line numbers if provided + if (filePath) { + tags.push(['file', filePath]); + } + if (lineStart !== undefined) { + tags.push(['line-start', lineStart.toString()]); + } + if (lineEnd !== undefined) { + tags.push(['line-end', lineEnd.toString()]); + } + + // Add context if provided + if (context) { + tags.push(['context', context]); + } + + // Add comment if provided (creates quote highlight) + if (comment) { + tags.push(['comment', comment]); + } + + return { + kind: KIND.HIGHLIGHT, + pubkey: '', // Will be filled by signer + created_at: Math.floor(Date.now() / 1000), + content: highlightedContent, + tags + }; + } + + /** + * Create a comment event template (NIP-22) + * + * @param content - Comment text + * @param rootEventId - Root event ID (PR or highlight) + * @param rootEventKind - Root event kind + * @param rootPubkey - Root event author pubkey + * @param parentEventId - Parent event ID (for replies) + * @param parentEventKind - Parent event kind + * @param parentPubkey - Parent event author pubkey + * @param rootEventAddress - Root event address (optional, for replaceable events) + */ + createCommentEvent( + content: string, + rootEventId: string, + rootEventKind: number, + rootPubkey: string, + parentEventId?: string, + parentEventKind?: number, + parentPubkey?: string, + rootEventAddress?: string + ): Omit { + const tags: string[][] = [ + ['E', rootEventId, '', rootPubkey], // Root event + ['K', rootEventKind.toString()], // Root kind + ['P', rootPubkey], // Root author + ]; + + // Add root event address if provided (for replaceable events) + if (rootEventAddress) { + tags.push(['A', rootEventAddress]); + } + + // Add parent references (for replies) + if (parentEventId) { + tags.push(['e', parentEventId, '', parentPubkey || rootPubkey]); + tags.push(['k', (parentEventKind || rootEventKind).toString()]); + if (parentPubkey) { + tags.push(['p', parentPubkey]); + } + } else { + // Top-level comment - parent is same as root + tags.push(['e', rootEventId, '', rootPubkey]); + tags.push(['k', rootEventKind.toString()]); + tags.push(['p', rootPubkey]); + } + + return { + kind: KIND.COMMENT, + pubkey: '', // Will be filled by signer + created_at: Math.floor(Date.now() / 1000), + content: content, + tags + }; + } +} diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index f543e71..04caa3b 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -7,26 +7,51 @@ import { NostrClient } from './nostr-client.js'; import { KIND } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js'; import { nip19 } from 'nostr-tools'; +import { OwnershipTransferService } from './ownership-transfer-service.js'; + +export interface RepoPrivacyInfo { + isPrivate: boolean; + owner: string; + maintainers: string[]; +} export class MaintainerService { private nostrClient: NostrClient; - private cache: Map = new Map(); + private ownershipTransferService: OwnershipTransferService; + private cache: Map = new Map(); private cacheTTL = 5 * 60 * 1000; // 5 minutes constructor(relays: string[]) { this.nostrClient = new NostrClient(relays); + this.ownershipTransferService = new OwnershipTransferService(relays); } /** - * Get maintainers for a repository from NIP-34 announcement + * Check if a repository is private + * A repo is private if it has a tag ["private", "true"] or ["t", "private"] */ - async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[] }> { + private isPrivateRepo(announcement: NostrEvent): boolean { + // Check for ["private", "true"] tag + const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); + if (privateTag) return true; + + // Check for ["t", "private"] tag (topic tag) + const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); + if (topicTag) return true; + + return false; + } + + /** + * Get maintainers and privacy info for a repository from NIP-34 announcement + */ + async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[]; isPrivate: boolean }> { const cacheKey = `${repoOwnerPubkey}:${repoId}`; const cached = this.cache.get(cacheKey); // Return cached if still valid if (cached && Date.now() - cached.timestamp < this.cacheTTL) { - return { owner: cached.owner, maintainers: cached.maintainers }; + return { owner: cached.owner, maintainers: cached.maintainers, isPrivate: cached.isPrivate }; } try { @@ -41,14 +66,24 @@ export class MaintainerService { ]); if (events.length === 0) { - // If no announcement found, only the owner is a maintainer - const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey] }; + // If no announcement found, only the owner is a maintainer, and repo is public by default + const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false }; this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } const announcement = events[0]; - const maintainers: string[] = [announcement.pubkey]; // Owner is always a maintainer + + // Check if repo is private + const isPrivate = this.isPrivateRepo(announcement); + + // Check for ownership transfers - get current owner + const currentOwner = await this.ownershipTransferService.getCurrentOwner( + announcement.pubkey, + repoId + ); + + const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer // Extract maintainers from tags for (const tag of announcement.tags) { @@ -70,13 +105,13 @@ export class MaintainerService { } } - const result = { owner: announcement.pubkey, maintainers }; + const result = { owner: currentOwner, maintainers, isPrivate }; this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } catch (error) { console.error('Error fetching maintainers:', error); - // Fallback: only owner is maintainer - const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey] }; + // Fallback: only owner is maintainer, repo is public by default + const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false }; this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); return result; } @@ -90,6 +125,47 @@ export class MaintainerService { return maintainers.includes(userPubkey); } + /** + * Check if a user can view a repository + * Public repos: anyone can view + * Private repos: only owners and maintainers can view + */ + async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise { + const { isPrivate, maintainers } = await this.getMaintainers(repoOwnerPubkey, repoId); + + // Public repos are viewable by anyone + if (!isPrivate) { + return true; + } + + // Private repos require authentication + if (!userPubkey) { + return false; + } + + // Convert userPubkey to hex if needed + let userPubkeyHex = userPubkey; + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + // Assume it's already a hex pubkey + } + + // Check if user is owner or maintainer + return maintainers.includes(userPubkeyHex); + } + + /** + * Get privacy info for a repository + */ + async getPrivacyInfo(repoOwnerPubkey: string, repoId: string): Promise { + const { owner, maintainers, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId); + return { isPrivate, owner, maintainers }; + } + /** * Clear cache for a repository (useful after maintainer changes) */ diff --git a/src/lib/services/nostr/nip98-auth.ts b/src/lib/services/nostr/nip98-auth.ts new file mode 100644 index 0000000..a619b84 --- /dev/null +++ b/src/lib/services/nostr/nip98-auth.ts @@ -0,0 +1,207 @@ +/** + * NIP-98 HTTP Authentication service + * Implements NIP-98 for authenticating HTTP requests using Nostr events + */ + +import { verifyEvent } from 'nostr-tools'; +import type { NostrEvent } from '../../types/nostr.js'; +import { createHash } from 'crypto'; + +export interface NIP98AuthResult { + valid: boolean; + error?: string; + event?: NostrEvent; + pubkey?: string; +} + +/** + * Verify NIP-98 authentication from Authorization header + * + * @param authHeader - The Authorization header value (should start with "Nostr ") + * @param requestUrl - The absolute request URL (including query parameters) + * @param requestMethod - The HTTP method (GET, POST, etc.) + * @param requestBody - Optional request body for payload verification + * @returns Authentication result with validation status + */ +export function verifyNIP98Auth( + authHeader: string | null, + requestUrl: string, + requestMethod: string, + requestBody?: ArrayBuffer | Buffer | string +): NIP98AuthResult { + // Check Authorization header format + if (!authHeader || !authHeader.startsWith('Nostr ')) { + return { + valid: false, + error: 'Missing or invalid Authorization header. Expected format: "Nostr "' + }; + } + + try { + // Decode base64 event + const base64Event = authHeader.slice(7); // Remove "Nostr " prefix + const eventJson = Buffer.from(base64Event, 'base64').toString('utf-8'); + const nostrEvent: NostrEvent = JSON.parse(eventJson); + + // Validate kind (must be 27235) + if (nostrEvent.kind !== 27235) { + return { + valid: false, + error: `Invalid event kind. Expected 27235, got ${nostrEvent.kind}` + }; + } + + // Validate content is empty (SHOULD be empty per spec) + if (nostrEvent.content && nostrEvent.content.trim() !== '') { + return { + valid: false, + error: 'Event content should be empty for NIP-98 authentication' + }; + } + + // Verify event signature + if (!verifyEvent(nostrEvent)) { + return { + valid: false, + error: 'Invalid event signature' + }; + } + + // Check created_at timestamp (within 60 seconds per spec) + const now = Math.floor(Date.now() / 1000); + const eventAge = now - nostrEvent.created_at; + if (eventAge > 60) { + return { + valid: false, + error: 'Authentication event is too old (must be within 60 seconds)' + }; + } + if (eventAge < 0) { + return { + valid: false, + error: 'Authentication event has future timestamp' + }; + } + + // Validate 'u' tag (must match exact request URL) + const uTag = nostrEvent.tags.find(t => t[0] === 'u'); + if (!uTag || !uTag[1]) { + return { + valid: false, + error: "Missing 'u' tag in authentication event" + }; + } + + // Normalize URLs for comparison (remove trailing slashes, handle encoding) + const normalizeUrl = (url: string): string => { + try { + const parsed = new URL(url); + // Remove trailing slash from pathname + parsed.pathname = parsed.pathname.replace(/\/$/, ''); + return parsed.toString(); + } catch { + return url; + } + }; + + const eventUrl = normalizeUrl(uTag[1]); + const requestUrlNormalized = normalizeUrl(requestUrl); + + if (eventUrl !== requestUrlNormalized) { + return { + valid: false, + error: `URL mismatch. Event URL: ${eventUrl}, Request URL: ${requestUrlNormalized}` + }; + } + + // Validate 'method' tag + const methodTag = nostrEvent.tags.find(t => t[0] === 'method'); + if (!methodTag || !methodTag[1]) { + return { + valid: false, + error: "Missing 'method' tag in authentication event" + }; + } + + if (methodTag[1].toUpperCase() !== requestMethod.toUpperCase()) { + return { + valid: false, + error: `HTTP method mismatch. Event method: ${methodTag[1]}, Request method: ${requestMethod}` + }; + } + + // Validate 'payload' tag if present (for POST/PUT/PATCH with body) + if (requestBody && ['POST', 'PUT', 'PATCH'].includes(requestMethod.toUpperCase())) { + const payloadTag = nostrEvent.tags.find(t => t[0] === 'payload'); + if (payloadTag && payloadTag[1]) { + // Calculate SHA256 of request body + const bodyBuffer = typeof requestBody === 'string' + ? Buffer.from(requestBody, 'utf-8') + : requestBody instanceof ArrayBuffer + ? Buffer.from(requestBody) + : requestBody; + + const bodyHash = createHash('sha256').update(bodyBuffer).digest('hex'); + + if (payloadTag[1].toLowerCase() !== bodyHash.toLowerCase()) { + return { + valid: false, + error: `Payload hash mismatch. Expected: ${payloadTag[1]}, Calculated: ${bodyHash}` + }; + } + } + } + + return { + valid: true, + event: nostrEvent, + pubkey: nostrEvent.pubkey + }; + } catch (err) { + return { + valid: false, + error: `Failed to parse or verify authentication: ${err instanceof Error ? err.message : String(err)}` + }; + } +} + +/** + * Create a NIP-98 authentication event + * This is a helper for clients to create properly formatted auth events + */ +export function createNIP98AuthEvent( + pubkey: string, + url: string, + method: string, + bodyHash?: string +): Omit { + const tags: string[][] = [ + ['u', url], + ['method', method.toUpperCase()] + ]; + + if (bodyHash) { + tags.push(['payload', bodyHash]); + } + + return { + kind: 27235, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + }; +} + +/** + * Calculate SHA256 hash of request body for payload tag + */ +export function calculateBodyHash(body: ArrayBuffer | Buffer | string): string { + const bodyBuffer = typeof body === 'string' + ? Buffer.from(body, 'utf-8') + : body instanceof ArrayBuffer + ? Buffer.from(body) + : body; + + return createHash('sha256').update(bodyBuffer).digest('hex'); +} diff --git a/src/lib/services/nostr/ownership-transfer-service.ts b/src/lib/services/nostr/ownership-transfer-service.ts new file mode 100644 index 0000000..aea02ac --- /dev/null +++ b/src/lib/services/nostr/ownership-transfer-service.ts @@ -0,0 +1,312 @@ +/** + * Service for handling repository ownership transfers + * Allows current owners to transfer ownership to another pubkey via Nostr events + */ + +import { NostrClient } from './nostr-client.js'; +import { KIND } from '../../types/nostr.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { verifyEvent } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; + +export interface OwnershipTransfer { + event: NostrEvent; + fromPubkey: string; + toPubkey: string; + repoId: string; + timestamp: number; +} + +/** + * Service for managing repository ownership transfers + */ +export class OwnershipTransferService { + private nostrClient: NostrClient; + private cache: Map = new Map(); + private cacheTTL = 5 * 60 * 1000; // 5 minutes + + constructor(relays: string[]) { + this.nostrClient = new NostrClient(relays); + } + + /** + * Get the current owner of a repository, checking for ownership transfers + * The initial ownership is proven by a self-transfer event (from owner to themselves) + * + * @param originalOwnerPubkey - The original owner from the repo announcement + * @param repoId - The repository identifier (d-tag) + * @returns The current owner pubkey (may be different from original if transferred) + */ + async getCurrentOwner(originalOwnerPubkey: string, repoId: string): Promise { + const cacheKey = `${originalOwnerPubkey}:${repoId}`; + const cached = this.cache.get(cacheKey); + + // Return cached if still valid + if (cached && Date.now() - cached.timestamp < this.cacheTTL) { + return cached.owner; + } + + try { + // Fetch all ownership transfer events for this repo + // We use the 'a' tag to reference the repo announcement + const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; + + const transferEvents = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.OWNERSHIP_TRANSFER], + '#a': [repoTag], + limit: 100 // Get all transfers to find the most recent valid one + } + ]); + + if (transferEvents.length === 0) { + // No transfer events found - check if there's a self-transfer from the original owner + // This would be the initial ownership proof + // For now, if no transfers exist, we fall back to original owner + // In the future, we might require a self-transfer event for initial ownership + const result = originalOwnerPubkey; + this.cache.set(cacheKey, { owner: result, timestamp: Date.now() }); + return result; + } + + // Sort by created_at ascending to process in chronological order + transferEvents.sort((a, b) => a.created_at - b.created_at); + + // Start with original owner, then apply transfers in chronological order + let currentOwner = originalOwnerPubkey; + const validTransfers: OwnershipTransfer[] = []; + + // Collect all valid transfers (including self-transfers for initial ownership proof) + for (const event of transferEvents) { + const transfer = this.parseTransferEvent(event, originalOwnerPubkey, repoId); + if (transfer && this.isValidTransfer(transfer, originalOwnerPubkey, validTransfers)) { + validTransfers.push(transfer); + } + } + + // Apply transfers in chronological order + for (const transfer of validTransfers) { + // Verify the transfer is from the current owner + // Self-transfers (from == to) don't change ownership but establish initial proof + if (transfer.fromPubkey === currentOwner) { + // Only change owner if it's not a self-transfer + if (transfer.fromPubkey !== transfer.toPubkey) { + currentOwner = transfer.toPubkey; + } + // Self-transfers are valid but don't change ownership + } + } + + this.cache.set(cacheKey, { owner: currentOwner, timestamp: Date.now() }); + return currentOwner; + } catch (error) { + console.error('Error fetching ownership transfers:', error); + // Fallback to original owner + return originalOwnerPubkey; + } + } + + /** + * Parse an ownership transfer event + */ + private parseTransferEvent( + event: NostrEvent, + originalOwnerPubkey: string, + repoId: string + ): OwnershipTransfer | null { + // Verify event signature + if (!verifyEvent(event)) { + return null; + } + + // Check that it's an ownership transfer event + if (event.kind !== KIND.OWNERSHIP_TRANSFER) { + return null; + } + + // Extract 'a' tag (repo reference) + const aTag = event.tags.find(t => t[0] === 'a'); + if (!aTag || !aTag[1]) { + return null; + } + + // Verify 'a' tag matches this repo + const expectedRepoTag = `30617:${originalOwnerPubkey}:${repoId}`; + if (aTag[1] !== expectedRepoTag) { + return null; + } + + // Extract 'p' tag (new owner) + const pTag = event.tags.find(t => t[0] === 'p'); + if (!pTag || !pTag[1]) { + return null; + } + + // Decode npub if needed + let toPubkey = pTag[1]; + try { + const decoded = nip19.decode(toPubkey); + if (decoded.type === 'npub') { + toPubkey = decoded.data as string; + } + } catch { + // Assume it's already a hex pubkey + } + + return { + event, + fromPubkey: event.pubkey, // Transfer is signed by current owner + toPubkey, + repoId, + timestamp: event.created_at + }; + } + + /** + * Validate that a transfer is valid + * A transfer is valid if: + * 1. It's signed by the current owner (at the time of transfer) + * 2. The event is properly formatted + * 3. Self-transfers (from owner to themselves) are valid for initial ownership proof + * + * @param transfer - The transfer to validate + * @param originalOwnerPubkey - The original owner from repo announcement + * @param previousTransfers - Previously validated transfers (for chain verification) + */ + private isValidTransfer( + transfer: OwnershipTransfer, + originalOwnerPubkey: string, + previousTransfers: OwnershipTransfer[] = [] + ): boolean { + // Self-transfers are valid (from owner to themselves) - used for initial ownership proof + if (transfer.fromPubkey === transfer.toPubkey) { + // Self-transfer must be from the original owner (initial ownership proof) + // or from a current owner (re-asserting ownership) + return transfer.fromPubkey === originalOwnerPubkey || + previousTransfers.some(t => t.toPubkey === transfer.fromPubkey); + } + + // Regular transfers must be from a valid owner + // Check if the fromPubkey is the original owner or a previous transfer recipient + const isValidFrom = transfer.fromPubkey === originalOwnerPubkey || + previousTransfers.some(t => t.toPubkey === transfer.fromPubkey); + + // Also check basic format + const validFormat = transfer.fromPubkey.length === 64 && + transfer.toPubkey.length === 64; + + return isValidFrom && validFormat; + } + + /** + * Create an ownership transfer event template + * + * @param fromPubkey - Current owner's pubkey + * @param toPubkey - New owner's pubkey (hex or npub). If same as fromPubkey, creates a self-transfer (initial ownership proof) + * @param originalOwnerPubkey - Original owner from repo announcement + * @param repoId - Repository identifier (d-tag) + * @returns Event template ready to be signed + */ + createTransferEvent( + fromPubkey: string, + toPubkey: string, + originalOwnerPubkey: string, + repoId: string + ): Omit { + // Decode npub if needed + let toPubkeyHex = toPubkey; + try { + const decoded = nip19.decode(toPubkey); + if (decoded.type === 'npub') { + toPubkeyHex = decoded.data as string; + } + } catch { + // Assume it's already a hex pubkey + } + + const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; + const isSelfTransfer = fromPubkey === toPubkeyHex; + const content = isSelfTransfer + ? `Initial ownership proof for repository ${repoId}` + : `Transferring ownership of repository ${repoId} to ${toPubkeyHex}`; + + return { + kind: KIND.OWNERSHIP_TRANSFER, + pubkey: fromPubkey, + created_at: Math.floor(Date.now() / 1000), + content: content, + tags: [ + ['a', repoTag], // Reference to repo announcement + ['p', toPubkeyHex], // New owner (or same owner for self-transfer) + ['d', repoId], // Repository identifier + ...(isSelfTransfer ? [['t', 'self-transfer']] : []), // Tag to indicate self-transfer + ] + }; + } + + /** + * Create an initial ownership proof event (self-transfer) + * This should be created when a repository is first announced + * + * @param ownerPubkey - Owner's pubkey + * @param repoId - Repository identifier (d-tag) + * @returns Event template ready to be signed + */ + createInitialOwnershipEvent( + ownerPubkey: string, + repoId: string + ): Omit { + return this.createTransferEvent(ownerPubkey, ownerPubkey, ownerPubkey, repoId); + } + + /** + * Verify that a user can initiate a transfer (must be current owner) + */ + async canTransfer(userPubkey: string, originalOwnerPubkey: string, repoId: string): Promise { + const currentOwner = await this.getCurrentOwner(originalOwnerPubkey, repoId); + return currentOwner === userPubkey; + } + + /** + * Clear cache for a repository (useful after ownership changes) + */ + clearCache(originalOwnerPubkey: string, repoId: string): void { + const cacheKey = `${originalOwnerPubkey}:${repoId}`; + this.cache.delete(cacheKey); + } + + /** + * Get all valid ownership transfers for a repository (for history/display) + */ + async getTransferHistory(originalOwnerPubkey: string, repoId: string): Promise { + try { + const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; + + const transferEvents = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.OWNERSHIP_TRANSFER], + '#a': [repoTag], + limit: 100 + } + ]); + + const transfers: OwnershipTransfer[] = []; + // Sort by timestamp to validate in order + const sortedEvents = [...transferEvents].sort((a, b) => a.created_at - b.created_at); + + for (const event of sortedEvents) { + const transfer = this.parseTransferEvent(event, originalOwnerPubkey, repoId); + if (transfer && this.isValidTransfer(transfer, originalOwnerPubkey, transfers)) { + transfers.push(transfer); + } + } + + // Sort by timestamp descending (most recent first) + transfers.sort((a, b) => b.timestamp - a.timestamp); + return transfers; + } catch (error) { + console.error('Error fetching transfer history:', error); + return []; + } + } +} diff --git a/src/lib/services/nostr/relay-write-proof.ts b/src/lib/services/nostr/relay-write-proof.ts index 8f931d5..9d70095 100644 --- a/src/lib/services/nostr/relay-write-proof.ts +++ b/src/lib/services/nostr/relay-write-proof.ts @@ -1,9 +1,12 @@ /** * Service for verifying that a user can write to at least one default relay * This replaces rate limiting by requiring proof of relay write capability + * + * Accepts NIP-98 events (kind 27235) as proof, since publishing a NIP-98 event + * to a relay proves the user can write to that relay. */ -import { verifyEvent, getEventHash } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools'; import type { NostrEvent } from '../../types/nostr.js'; import { NostrClient } from './nostr-client.js'; import { DEFAULT_NOSTR_RELAYS } from '../../config.js'; @@ -16,7 +19,13 @@ export interface RelayWriteProof { /** * Verify that a user can write to at least one default relay - * The proof should be a recent event (within last 5 minutes) published to a default relay + * + * Accepts: + * - NIP-98 events (kind 27235) - preferred, since they're already used for HTTP auth + * - Kind 1 (text note) events - for backward compatibility + * + * The proof should be a recent event (within 60 seconds for NIP-98, 5 minutes for kind 1) + * published to a default relay. */ export async function verifyRelayWriteProof( proofEvent: NostrEvent, @@ -33,16 +42,43 @@ export async function verifyRelayWriteProof( return { valid: false, error: 'Event pubkey does not match user pubkey' }; } - // Verify the event is recent (within last 5 minutes) + // Determine time window based on event kind + // NIP-98 events (27235) should be within 60 seconds per spec + // Other events (like kind 1) can be within 5 minutes + const isNIP98Event = proofEvent.kind === 27235; + const maxAge = isNIP98Event ? 60 : 300; // 60 seconds for NIP-98, 5 minutes for others + + // Verify the event is recent const now = Math.floor(Date.now() / 1000); const eventAge = now - proofEvent.created_at; - if (eventAge > 300) { // 5 minutes - return { valid: false, error: 'Proof event is too old (must be within 5 minutes)' }; + if (eventAge > maxAge) { + return { + valid: false, + error: `Proof event is too old (must be within ${maxAge} seconds${isNIP98Event ? ' for NIP-98 events' : ''})` + }; } if (eventAge < 0) { return { valid: false, error: 'Proof event has future timestamp' }; } + // For NIP-98 events, validate they have required tags + if (isNIP98Event) { + const uTag = proofEvent.tags.find(t => t[0] === 'u'); + const methodTag = proofEvent.tags.find(t => t[0] === 'method'); + + if (!uTag || !uTag[1]) { + return { valid: false, error: "NIP-98 event missing 'u' tag" }; + } + if (!methodTag || !methodTag[1]) { + return { valid: false, error: "NIP-98 event missing 'method' tag" }; + } + + // Content should be empty for NIP-98 + if (proofEvent.content && proofEvent.content.trim() !== '') { + return { valid: false, error: 'NIP-98 event content should be empty' }; + } + } + // Try to verify the event exists on at least one default relay const nostrClient = new NostrClient(relays); try { @@ -76,7 +112,11 @@ export async function verifyRelayWriteProof( /** * Create a proof event that can be used to prove relay write capability - * This is a simple kind 1 (text note) event with a specific content + * + * For new implementations, prefer using NIP-98 events (kind 27235) as they + * serve dual purpose: HTTP authentication and relay write proof. + * + * This function creates a simple kind 1 event for backward compatibility. */ export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit { return { @@ -87,3 +127,41 @@ export function createProofEvent(userPubkey: string, content: string = 'gitrepub tags: [['t', 'gitrepublic-proof']] }; } + +/** + * Verify relay write proof from NIP-98 Authorization header + * This is a convenience function that extracts the NIP-98 event from the + * Authorization header and verifies it as relay write proof. + * + * @param authHeader - The Authorization header value (should start with "Nostr ") + * @param userPubkey - The expected user pubkey + * @param relays - List of relays to check (defaults to DEFAULT_NOSTR_RELAYS) + * @returns Verification result + */ +export async function verifyRelayWriteProofFromAuth( + authHeader: string | null, + userPubkey: string, + relays: string[] = DEFAULT_NOSTR_RELAYS +): Promise<{ valid: boolean; error?: string; relay?: string }> { + if (!authHeader || !authHeader.startsWith('Nostr ')) { + return { + valid: false, + error: 'Missing or invalid Authorization header. Expected format: "Nostr "' + }; + } + + try { + // Decode base64 event + const base64Event = authHeader.slice(7); // Remove "Nostr " prefix + const eventJson = Buffer.from(base64Event, 'base64').toString('utf-8'); + const proofEvent: NostrEvent = JSON.parse(eventJson); + + // Verify as relay write proof + return await verifyRelayWriteProof(proofEvent, userPubkey, relays); + } catch (err) { + return { + valid: false, + error: `Failed to parse Authorization header: ${err instanceof Error ? err.message : String(err)}` + }; + } +} diff --git a/src/lib/services/nostr/repo-polling.ts b/src/lib/services/nostr/repo-polling.ts index 3ea4b49..c24e089 100644 --- a/src/lib/services/nostr/repo-polling.ts +++ b/src/lib/services/nostr/repo-polling.ts @@ -6,6 +6,7 @@ import { NostrClient } from './nostr-client.js'; import { KIND } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js'; import { RepoManager } from '../git/repo-manager.js'; +import { OwnershipTransferService } from './ownership-transfer-service.js'; export class RepoPollingService { private nostrClient: NostrClient; @@ -13,6 +14,7 @@ export class RepoPollingService { private pollingInterval: number; private intervalId: NodeJS.Timeout | null = null; private domain: string; + private relays: string[]; constructor( relays: string[], @@ -20,6 +22,7 @@ export class RepoPollingService { domain: string, pollingInterval: number = 60000 // 1 minute ) { + this.relays = relays; this.nostrClient = new NostrClient(relays); this.repoManager = new RepoManager(repoRoot, domain); this.pollingInterval = pollingInterval; @@ -74,8 +77,91 @@ export class RepoPollingService { // Provision each repo for (const event of relevantEvents) { try { - await this.repoManager.provisionRepo(event); - console.log(`Provisioned repo from announcement ${event.id}`); + // Extract repo ID from d-tag + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (!dTag) { + console.warn(`Repo announcement ${event.id} missing d-tag`); + continue; + } + + // Check if this is an existing repo or new repo + const cloneUrls = this.extractCloneUrls(event); + const domainUrl = cloneUrls.find(url => url.includes(this.domain)); + if (!domainUrl) continue; + + const repoPath = this.repoManager.parseRepoUrl(domainUrl); + if (!repoPath) continue; + + const repoExists = this.repoManager.repoExists(repoPath.fullPath); + const isExistingRepo = repoExists; + + // Fetch self-transfer event for this repo + const ownershipService = new OwnershipTransferService(this.relays); + const repoTag = `30617:${event.pubkey}:${dTag}`; + + const selfTransferEvents = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.OWNERSHIP_TRANSFER], + '#a': [repoTag], + authors: [event.pubkey], + limit: 10 + } + ]); + + // Find self-transfer event (from owner to themselves) + let selfTransferEvent: NostrEvent | undefined; + for (const transferEvent of selfTransferEvents) { + const pTag = transferEvent.tags.find(t => t[0] === 'p'); + if (pTag && pTag[1] === event.pubkey) { + // Decode npub if needed + let toPubkey = pTag[1]; + try { + const { nip19 } = await import('nostr-tools'); + const decoded = nip19.decode(toPubkey); + if (decoded.type === 'npub') { + toPubkey = decoded.data as string; + } + } catch { + // Assume it's already hex + } + + if (transferEvent.pubkey === event.pubkey && toPubkey === event.pubkey) { + selfTransferEvent = transferEvent; + break; + } + } + } + + // For existing repos without self-transfer, create one retroactively + if (isExistingRepo && !selfTransferEvent) { + console.log(`Existing repo ${dTag} from ${event.pubkey} has no self-transfer event. Creating template for owner to sign and publish.`); + + try { + // Create a self-transfer event template for the existing repo + // The owner will need to sign and publish this to relays + const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(event.pubkey, dTag); + + // Create an unsigned event template that can be included in the repo + // This serves as a reference and the owner can use it to create the actual event + const selfTransferTemplate = { + ...initialOwnershipEvent, + id: '', // Will be computed when signed + sig: '', // Needs owner signature + _note: 'This is a template. The owner must sign and publish this event to relays for it to be valid.' + } as NostrEvent & { _note?: string }; + + // Use the template (even though it's unsigned, it will be included in the repo) + selfTransferEvent = selfTransferTemplate; + + console.warn(`Self-transfer event template created for ${dTag}. Owner ${event.pubkey} should sign and publish it to relays.`); + } catch (err) { + console.error(`Failed to create self-transfer event template for ${dTag}:`, err); + } + } + + // Provision the repo with self-transfer event if available + await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo); + console.log(`Provisioned repo from announcement ${event.id}${isExistingRepo ? ' (existing)' : ' (new)'}`); } catch (error) { console.error(`Failed to provision repo from ${event.id}:`, error); } diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 6cbd9d8..308a747 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -19,6 +19,7 @@ export interface NostrFilter { '#e'?: string[]; '#p'?: string[]; '#d'?: string[]; + '#a'?: string[]; since?: number; until?: number; limit?: number; @@ -27,6 +28,7 @@ export interface NostrFilter { export const KIND = { REPO_ANNOUNCEMENT: 30617, REPO_STATE: 30618, + OWNERSHIP_TRANSFER: 30619, // Repository ownership transfer event PATCH: 1617, PULL_REQUEST: 1618, PULL_REQUEST_UPDATE: 1619, @@ -35,6 +37,8 @@ export const KIND = { STATUS_APPLIED: 1631, STATUS_CLOSED: 1632, STATUS_DRAFT: 1633, + HIGHLIGHT: 9802, // NIP-84: Highlight event + COMMENT: 1111, // NIP-22: Comment event } as const; export interface Issue extends NostrEvent { diff --git a/src/lib/utils/repo-privacy.ts b/src/lib/utils/repo-privacy.ts new file mode 100644 index 0000000..c7e1a4d --- /dev/null +++ b/src/lib/utils/repo-privacy.ts @@ -0,0 +1,49 @@ +/** + * Helper utilities for checking repository privacy + */ + +import { nip19 } from 'nostr-tools'; +import { MaintainerService } from '../services/nostr/maintainer-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '../config.js'; + +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + +/** + * Check if a user can view a repository + * Returns the repo owner pubkey and whether access is allowed + */ +export async function checkRepoAccess( + npub: string, + repo: string, + userPubkey: string | null +): Promise<{ allowed: boolean; repoOwnerPubkey: string; error?: string }> { + try { + // Decode npub to get pubkey + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return { allowed: false, repoOwnerPubkey: '', error: 'Invalid npub format' }; + } + } catch { + return { allowed: false, repoOwnerPubkey: '', error: 'Invalid npub format' }; + } + + // Check if user can view + const canView = await maintainerService.canView(userPubkey, repoOwnerPubkey, repo); + + return { + allowed: canView, + repoOwnerPubkey, + ...(canView ? {} : { error: 'This repository is private. Only owners and maintainers can view it.' }) + }; + } catch (error) { + return { + allowed: false, + repoOwnerPubkey: '', + error: error instanceof Error ? error.message : 'Failed to check repository access' + }; + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f3835b2..f4bc95f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -31,14 +31,23 @@ // Get git domain from layout data const gitDomain = $page.data.gitDomain || 'localhost:6543'; - // Filter for repos that list our domain in clone tags + // Filter for repos that list our domain in clone tags and are public repos = events.filter(event => { const cloneUrls = event.tags .filter(t => t[0] === 'clone') .flatMap(t => t.slice(1)) .filter(url => url && typeof url === 'string'); - return cloneUrls.some(url => url.includes(gitDomain)); + const hasDomain = cloneUrls.some(url => url.includes(gitDomain)); + if (!hasDomain) return false; + + // Filter out private repos from public listing + const isPrivate = event.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + + return !isPrivate; // Only show public repos }); // Sort by created_at descending diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index 420ee43..bb9cb64 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -6,7 +6,6 @@ import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { RepoManager } from '$lib/services/git/repo-manager.js'; -import { verifyEvent } from 'nostr-tools'; import { nip19 } from 'nostr-tools'; import { spawn, execSync } from 'child_process'; import { existsSync } from 'fs'; @@ -15,10 +14,15 @@ import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { KIND } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js'; +import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js'; +import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoManager = new RepoManager(repoRoot); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); +const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); // Path to git-http-backend (common locations) const GIT_HTTP_BACKEND_PATHS = [ @@ -52,59 +56,6 @@ function findGitHttpBackend(): string | null { return null; } -/** - * Verify NIP-98 authentication for push operations - */ -async function verifyNIP98Auth( - request: Request, - expectedPubkey: string -): Promise<{ valid: boolean; error?: string }> { - const authHeader = request.headers.get('Authorization'); - - if (!authHeader || !authHeader.startsWith('Nostr ')) { - return { valid: false, error: 'Missing or invalid Authorization header (expected "Nostr ")' }; - } - - try { - const eventJson = authHeader.slice(7); // Remove "Nostr " prefix - const nostrEvent: NostrEvent = JSON.parse(eventJson); - - // Verify event signature - if (!verifyEvent(nostrEvent)) { - return { valid: false, error: 'Invalid event signature' }; - } - - // Verify pubkey matches repo owner - if (nostrEvent.pubkey !== expectedPubkey) { - return { valid: false, error: 'Event pubkey does not match repository owner' }; - } - - // Verify event is recent (within last 5 minutes) - const now = Math.floor(Date.now() / 1000); - const eventAge = now - nostrEvent.created_at; - if (eventAge > 300) { - return { valid: false, error: 'Authentication event is too old (must be within 5 minutes)' }; - } - if (eventAge < 0) { - return { valid: false, error: 'Authentication event has future timestamp' }; - } - - // Verify the event method and URL match the request - const methodTag = nostrEvent.tags.find(t => t[0] === 'method'); - const urlTag = nostrEvent.tags.find(t => t[0] === 'u'); - - if (methodTag && methodTag[1] !== request.method) { - return { valid: false, error: 'Event method does not match request method' }; - } - - return { valid: true }; - } catch (err) { - return { - valid: false, - error: `Failed to parse or verify authentication: ${err instanceof Error ? err.message : String(err)}` - }; - } -} /** * Get repository announcement to extract clone URLs for post-receive sync @@ -162,6 +113,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const [, npub, repoName, gitPath = ''] = match; const service = url.searchParams.get('service'); + // Build absolute request URL for NIP-98 validation + const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http'); + const host = request.headers.get('host') || url.host; + const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`; + // Validate npub format try { const decoded = nip19.decode(npub); @@ -178,6 +134,52 @@ export const GET: RequestHandler = async ({ params, url, request }) => { return error(404, 'Repository not found'); } + // Check repository privacy for clone/fetch operations + let originalOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return error(400, 'Invalid npub format'); + } + originalOwnerPubkey = decoded.data as string; + } catch { + return error(400, 'Invalid npub format'); + } + + // For clone/fetch operations, check if repo is private + // If private, require NIP-98 authentication + const privacyInfo = await maintainerService.getPrivacyInfo(originalOwnerPubkey, repoName); + if (privacyInfo.isPrivate) { + // Private repos require authentication for clone/fetch + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Nostr ')) { + return error(401, 'This repository is private. Authentication required.'); + } + + // Build absolute request URL for NIP-98 validation + const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http'); + const host = request.headers.get('host') || url.host; + const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`; + + // Verify NIP-98 authentication + const authResult = verifyNIP98Auth( + authHeader, + requestUrl, + request.method, + undefined // GET requests don't have body + ); + + if (!authResult.valid) { + return error(401, authResult.error || 'Authentication required'); + } + + // Verify user can view the repo + const canView = await maintainerService.canView(authResult.pubkey || null, originalOwnerPubkey, repoName); + if (!canView) { + return error(403, 'You do not have permission to access this private repository.'); + } + } + // Find git-http-backend const gitHttpBackend = findGitHttpBackend(); if (!gitHttpBackend) { @@ -265,13 +267,13 @@ export const POST: RequestHandler = async ({ params, url, request }) => { const [, npub, repoName, gitPath = ''] = match; // Validate npub format and decode to get pubkey - let repoOwnerPubkey: string; + let originalOwnerPubkey: string; try { const decoded = nip19.decode(npub); if (decoded.type !== 'npub') { return error(400, 'Invalid npub format'); } - repoOwnerPubkey = decoded.data as string; + originalOwnerPubkey = decoded.data as string; } catch { return error(400, 'Invalid npub format'); } @@ -282,12 +284,36 @@ export const POST: RequestHandler = async ({ params, url, request }) => { return error(404, 'Repository not found'); } + // Get current owner (may be different if ownership was transferred) + const currentOwnerPubkey = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repoName); + + // Build absolute request URL for NIP-98 validation + const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http'); + const host = request.headers.get('host') || url.host; + const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`; + + // Get request body (read once, use for both auth and git-http-backend) + const body = await request.arrayBuffer(); + const bodyBuffer = Buffer.from(body); + // For push operations (git-receive-pack), require NIP-98 authentication if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) { - const authResult = await verifyNIP98Auth(request, repoOwnerPubkey); + // Verify NIP-98 authentication + const authResult = verifyNIP98Auth( + request.headers.get('Authorization'), + requestUrl, + request.method, + bodyBuffer.length > 0 ? bodyBuffer : undefined + ); + if (!authResult.valid) { return error(401, authResult.error || 'Authentication required'); } + + // Verify pubkey matches current repo owner (may have been transferred) + if (authResult.pubkey !== currentOwnerPubkey) { + return error(403, 'Event pubkey does not match repository owner'); + } } // Find git-http-backend @@ -299,10 +325,6 @@ export const POST: RequestHandler = async ({ params, url, request }) => { // Build PATH_INFO const pathInfo = gitPath ? `/${npub}/${repoName}.git/${gitPath}` : `/${npub}/${repoName}.git`; - // Get request body - const body = await request.arrayBuffer(); - const bodyBuffer = Buffer.from(body); - // Set up environment variables for git-http-backend const envVars = { ...process.env, diff --git a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts index 7ba8011..4b4e991 100644 --- a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts @@ -9,11 +9,12 @@ import { FileManager } from '$lib/services/git/file-manager.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const { npub, repo } = params; const branch = url.searchParams.get('branch') || 'main'; const limit = parseInt(url.searchParams.get('limit') || '50', 10); const path = url.searchParams.get('path') || undefined; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { return error(400, 'Missing npub or repo parameter'); @@ -24,6 +25,13 @@ export const GET: RequestHandler = async ({ params, url }) => { return error(404, 'Repository not found'); } + // Check repository privacy + const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); + const access = await checkRepoAccess(npub, repo, userPubkey || null); + if (!access.allowed) { + return error(403, access.error || 'Access denied'); + } + const commits = await fileManager.getCommitHistory(npub, repo, branch, limit, path); return json(commits); } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/diff/+server.ts b/src/routes/api/repos/[npub]/[repo]/diff/+server.ts index 82ddec8..145e1da 100644 --- a/src/routes/api/repos/[npub]/[repo]/diff/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/diff/+server.ts @@ -9,11 +9,12 @@ import { FileManager } from '$lib/services/git/file-manager.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const { npub, repo } = params; const fromRef = url.searchParams.get('from'); const toRef = url.searchParams.get('to') || 'HEAD'; const filePath = url.searchParams.get('path') || undefined; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo || !fromRef) { return error(400, 'Missing npub, repo, or from parameter'); @@ -24,6 +25,13 @@ export const GET: RequestHandler = async ({ params, url }) => { return error(404, 'Repository not found'); } + // Check repository privacy + const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); + const access = await checkRepoAccess(npub, repo, userPubkey || null); + if (!access.allowed) { + return error(403, access.error || 'Access denied'); + } + const diffs = await fileManager.getDiff(npub, repo, fromRef, toRef, filePath); return json(diffs); } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index 9ac2ba5..ed8d7dd 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -14,10 +14,11 @@ const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); -export const GET: RequestHandler = async ({ params, url }: { params: { npub?: string; repo?: string }; url: URL }) => { +export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { const { npub, repo } = params; const filePath = url.searchParams.get('path'); const ref = url.searchParams.get('ref') || 'HEAD'; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo || !filePath) { return error(400, 'Missing npub, repo, or path parameter'); @@ -28,6 +29,24 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st return error(404, 'Repository not found'); } + // Check repository privacy + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); + if (!canView) { + return error(403, 'This repository is private. Only owners and maintainers can view it.'); + } + const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref); return json(fileContent); } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts new file mode 100644 index 0000000..3ac1716 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts @@ -0,0 +1,129 @@ +/** + * API endpoint for Highlights (NIP-84 kind 9802) and Comments (NIP-22 kind 1111) + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { HighlightsService } from '$lib/services/nostr/highlights-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { nip19 } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools'; +import type { NostrEvent } from '$lib/types/nostr.js'; +import { combineRelays } from '$lib/config.js'; +import { getUserRelays } from '$lib/services/nostr/user-relays.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; + +const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); +const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + +/** + * GET - Get highlights for a pull request + * Query params: prId, prAuthor + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { npub, repo } = params; + const prId = url.searchParams.get('prId'); + const prAuthor = url.searchParams.get('prAuthor'); + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + if (!prId || !prAuthor) { + return error(400, 'Missing prId or prAuthor parameter'); + } + + try { + // Decode npub to get pubkey + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Decode prAuthor if it's an npub + let prAuthorPubkey = prAuthor; + try { + const decoded = nip19.decode(prAuthor); + if (decoded.type === 'npub') { + prAuthorPubkey = decoded.data as string; + } + } catch { + // Assume it's already hex + } + + // Get highlights for the PR + const highlights = await highlightsService.getHighlightsForPR( + prId, + prAuthorPubkey, + repoOwnerPubkey, + repo + ); + + // Also get top-level comments on the PR + const prComments = await highlightsService.getCommentsForPR(prId); + + return json({ + highlights, + comments: prComments + }); + } catch (err) { + console.error('Error fetching highlights:', err); + return error(500, err instanceof Error ? err.message : 'Failed to fetch highlights'); + } +}; + +/** + * POST - Create a highlight or comment + * Body: { type: 'highlight' | 'comment', event, userPubkey } + */ +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 { type, event, userPubkey } = body; + + if (!type || !event || !userPubkey) { + return error(400, 'Missing type, event, or userPubkey in request body'); + } + + if (type !== 'highlight' && type !== 'comment') { + return error(400, 'Type must be "highlight" or "comment"'); + } + + // Verify the event is properly signed + if (!event.sig || !event.id) { + return error(400, 'Invalid event: missing signature or ID'); + } + + if (!verifyEvent(event)) { + return error(400, 'Invalid event signature'); + } + + // Get user's relays and publish + const { outbox } = await getUserRelays(userPubkey, nostrClient); + const combinedRelays = combineRelays(outbox); + + const result = await highlightsService['nostrClient'].publishEvent(event as NostrEvent, combinedRelays); + + if (result.failed.length > 0 && result.success.length === 0) { + return error(500, 'Failed to publish to all relays'); + } + + return json({ success: true, event, published: result }); + } catch (err) { + console.error('Error creating highlight/comment:', err); + return error(500, err instanceof Error ? err.message : 'Failed to create highlight/comment'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts index d20be5a..a6fab84 100644 --- a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts @@ -8,8 +8,9 @@ import { IssuesService } from '$lib/services/nostr/issues-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const { npub, repo } = params; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { return error(400, 'Missing npub or repo parameter'); @@ -23,6 +24,13 @@ export const GET: RequestHandler = async ({ params, url }) => { } const repoOwnerPubkey = decoded.data as string; + // Check repository privacy + const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); + const access = await checkRepoAccess(npub, repo, userPubkey || null); + if (!access.allowed) { + return error(403, access.error || 'Access denied'); + } + const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS); const issues = await issuesService.getIssues(repoOwnerPubkey, repo); diff --git a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts index 492b2d1..106cf19 100644 --- a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts @@ -9,8 +9,9 @@ import { PRsService } from '$lib/services/nostr/prs-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; -export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => { +export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { const { npub, repo } = params; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { return error(400, 'Missing npub or repo parameter'); @@ -30,6 +31,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; return error(400, 'Invalid npub format'); } + // Check repository privacy + const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); + const access = await checkRepoAccess(npub, repo, userPubkey || null); + if (!access.allowed) { + return error(403, access.error || 'Access denied'); + } + const prsService = new PRsService(DEFAULT_NOSTR_RELAYS); const prs = await prsService.getPullRequests(repoOwnerPubkey, repo); diff --git a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts index 74d2df1..3e5b888 100644 --- a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts @@ -14,8 +14,9 @@ const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); -export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => { +export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { const { npub, repo } = params; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { return error(400, 'Missing npub or repo parameter'); @@ -26,6 +27,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; return error(404, 'Repository not found'); } + // Check repository privacy + const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); + const access = await checkRepoAccess(npub, repo, userPubkey || null); + if (!access.allowed) { + return error(403, access.error || 'Access denied'); + } + const tags = await fileManager.getTags(npub, repo); return json(tags); } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts new file mode 100644 index 0000000..115e02e --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts @@ -0,0 +1,172 @@ +/** + * API endpoint for transferring repository ownership + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; +import { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools'; +import type { NostrEvent } from '$lib/types/nostr.js'; +import { getUserRelays } from '$lib/services/nostr/user-relays.js'; + +const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); +const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + +/** + * GET - Get current owner and transfer history + */ +export const GET: RequestHandler = async ({ params }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + // Decode npub to get pubkey + let originalOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + originalOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Get current owner (may be different if transferred) + const currentOwner = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repo); + + // Fetch transfer events for history + const repoTag = `30617:${originalOwnerPubkey}:${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: originalOwnerPubkey, + currentOwner, + transferred: currentOwner !== originalOwnerPubkey, + 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() + }; + }) + }); + } catch (err) { + console.error('Error fetching ownership info:', err); + return error(500, err instanceof Error ? err.message : 'Failed to fetch ownership info'); + } +}; + +/** + * POST - Initiate ownership transfer + * Requires a pre-signed NIP-98 authenticated event from the current owner + */ +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 { transferEvent, userPubkey } = body; + + if (!transferEvent || !userPubkey) { + return error(400, 'Missing transferEvent or userPubkey in request body'); + } + + // Verify the event is properly signed + if (!transferEvent.sig || !transferEvent.id) { + return error(400, 'Invalid event: missing signature or ID'); + } + + if (!verifyEvent(transferEvent)) { + return error(400, 'Invalid event signature'); + } + + // Decode npub to get original owner pubkey + let originalOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + originalOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Verify user is the current owner + const canTransfer = await ownershipTransferService.canTransfer( + userPubkey, + originalOwnerPubkey, + repo + ); + + if (!canTransfer) { + return error(403, 'Only the current repository owner can transfer ownership'); + } + + // Verify the transfer event is from the current owner + if (transferEvent.pubkey !== userPubkey) { + return error(403, 'Transfer event must be signed by the current owner'); + } + + // Verify it's an ownership transfer event + if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { + return error(400, 'Event must be kind 30619 (ownership transfer)'); + } + + // Verify the 'a' tag references this repo + const aTag = transferEvent.tags.find(t => t[0] === 'a'); + const expectedRepoTag = `30617:${originalOwnerPubkey}:${repo}`; + if (!aTag || aTag[1] !== expectedRepoTag) { + return error(400, "Transfer event 'a' tag does not match this repository"); + } + + // Get user's relays and publish + const { outbox } = await getUserRelays(userPubkey, nostrClient); + const combinedRelays = combineRelays(outbox); + + const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays); + + if (result.success.length === 0) { + return error(500, 'Failed to publish transfer event to any relays'); + } + + // Clear cache so new owner is recognized immediately + ownershipTransferService.clearCache(originalOwnerPubkey, repo); + + return json({ + success: true, + event: transferEvent, + published: result, + message: 'Ownership transfer initiated successfully' + }); + } catch (err) { + console.error('Error transferring ownership:', err); + return error(500, err instanceof Error ? err.message : 'Failed to transfer ownership'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index 6bf9a16..121a599 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -5,14 +5,19 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { FileManager } from '$lib/services/git/file-manager.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { nip19 } from 'nostr-tools'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const { npub, repo } = params; const ref = url.searchParams.get('ref') || 'HEAD'; const path = url.searchParams.get('path') || ''; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { return error(400, 'Missing npub or repo parameter'); @@ -23,6 +28,24 @@ export const GET: RequestHandler = async ({ params, url }) => { return error(404, 'Repository not found'); } + // Check repository privacy + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); + if (!canView) { + return error(403, 'This repository is private. Only owners and maintainers can view it.'); + } + const files = await fileManager.listFiles(npub, repo, ref, path); return json(files); } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts index 506038f..2f4246c 100644 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts @@ -8,6 +8,7 @@ import type { RequestHandler } from './$types'; import { FileManager } from '$lib/services/git/file-manager.js'; import { verifyRepositoryOwnership, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; @@ -17,6 +18,7 @@ import { join } from 'path'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); +const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => { const { npub, repo } = params; @@ -45,19 +47,6 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; return error(404, 'Repository not found'); } - // Try to read verification file - let verificationContent: string; - try { - const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD'); - verificationContent = verificationFile.content; - } catch (err) { - return json({ - verified: false, - error: 'Verification file not found in repository', - message: 'This repository does not have a .nostr-verification file. It may have been created before verification was implemented.' - }); - } - // Fetch the repository announcement const events = await nostrClient.fetchEvents([ { @@ -78,21 +67,84 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; const announcement = events[0]; - // Verify ownership - const verification = verifyRepositoryOwnership(announcement, verificationContent); + // Check for ownership transfer events (including self-transfer for initial ownership) + const repoTag = `30617:${ownerPubkey}:${repo}`; + const transferEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.OWNERSHIP_TRANSFER], + '#a': [repoTag], + limit: 100 + } + ]); + + // Look for self-transfer event (initial ownership proof) + // Self-transfer: from owner to themselves, tagged with 'self-transfer' + const selfTransfer = transferEvents.find(event => { + const pTag = event.tags.find(t => t[0] === 'p'); + let toPubkey = pTag?.[1]; + + // Decode npub if needed + if (toPubkey) { + try { + const decoded = nip19.decode(toPubkey); + if (decoded.type === 'npub') { + toPubkey = decoded.data as string; + } + } catch { + // Assume it's already hex + } + } + + return event.pubkey === ownerPubkey && + toPubkey === ownerPubkey; + }); + + // Verify ownership - prefer self-transfer event, fall back to verification file + let verified = false; + let verificationMethod = ''; + let error: string | undefined; + + if (selfTransfer) { + // Verify self-transfer event signature + const { verifyEvent } = await import('nostr-tools'); + if (verifyEvent(selfTransfer)) { + verified = true; + verificationMethod = 'self-transfer-event'; + } else { + verified = false; + error = 'Self-transfer event signature is invalid'; + verificationMethod = 'self-transfer-event'; + } + } else { + // Fall back to verification file method (for backward compatibility) + try { + const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD'); + const verification = verifyRepositoryOwnership(announcement, verificationFile.content); + verified = verification.valid; + error = verification.error; + verificationMethod = 'verification-file'; + } catch (err) { + verified = false; + error = 'No ownership proof found (neither self-transfer event nor verification file)'; + verificationMethod = 'none'; + } + } - if (verification.valid) { + if (verified) { return json({ verified: true, announcementId: announcement.id, ownerPubkey: ownerPubkey, + verificationMethod, + selfTransferEventId: selfTransfer?.id, message: 'Repository ownership verified successfully' }); } else { return json({ verified: false, - error: verification.error, + error: error || 'Repository ownership verification failed', announcementId: announcement.id, + verificationMethod, message: 'Repository ownership verification failed' }); } diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 1b18f90..1a93e56 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import CodeEditor from '$lib/components/CodeEditor.svelte'; + import PRDetail from '$lib/components/PRDetail.svelte'; import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; @@ -81,6 +82,7 @@ let newPRCommitId = $state(''); let newPRBranchName = $state(''); let newPRLabels = $state(['']); + let selectedPR = $state(null); onMount(async () => { await loadBranches(); @@ -1046,9 +1048,23 @@

No pull requests found. Create one to get started!

+ {:else if selectedPR} + {#each prs.filter(p => p.id === selectedPR) as pr} + {@const decoded = nip19.decode(npub)} + {#if decoded.type === 'npub'} + {@const repoOwnerPubkey = decoded.data as string} + + + {/if} + {/each} {:else} {#each prs as pr} -
+
selectedPR = pr.id} style="cursor: pointer;">

{pr.subject}

diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index 6620c79..95ca586 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -187,10 +187,22 @@ // Combine user's outbox with default relays const userRelays = combineRelays(outbox); - // Publish to user's outboxes and standard relays + // Publish repository announcement const result = await nostrClient.publishEvent(signedEvent, userRelays); if (result.success.length > 0) { + // Create and publish initial ownership proof (self-transfer event) + const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js'); + const ownershipService = new OwnershipTransferService(userRelays); + + const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(pubkey, dTag); + const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); + + // Publish initial ownership event (don't fail if this fails, announcement is already published) + await nostrClient.publishEvent(signedOwnershipEvent, userRelays).catch(err => { + console.warn('Failed to publish initial ownership event:', err); + }); + success = true; setTimeout(() => { goto('/');